Compare commits

...

34 Commits

Author SHA1 Message Date
Paul Nothaft e4077832ef fix(backend): set multer's fieldArrayIndexLimit to actually close CVE-2026-82333
The advisory is explicit that the 2.3.0 version bump alone doesn't
remediate the array-index DoS — an app must also set
limits.fieldArrayIndexLimit. Set it on every multer instance, sized to
what each route's form actually needs.
2026-09-10 23:41:39 +02:00
Paul Nothaft 2bf160e1b4 fix(backend): bump sharp, nodemailer, multer, js-yaml, joi for security fixes
Backport of the same dependency bump on main (#1374). Resolves the
same 12 code-scanning alerts flagged on stable's backend deps: sharp
libheif RCE, nodemailer address-parser ReDoS + domain-validation
bypasses, multer upload DoS/race conditions, js-yaml parsing DoS, and
joi prototype pollution. All patch/minor bumps within the currently
used major version.
2026-09-10 19:18:38 +02:00
Paul Nothaft 1316ed05b3 fix(video): try metadata extraction and thumbnail generation independently (#1372)
* 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:56 +02:00
Paul Nothaft 34207456e6 fix(backup): honor the configured database-backup destination path (#1367)
* 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:26 +02:00
Paul Nothaft 6d906349bf chore(stable): release 3.46.11 (#1356)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-09-08 22:39:40 +02:00
Paul Nothaft 143c4035ec docs: align stable security and backport policy (#1352)
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-08 08:33:00 +02:00
Paul Nothaft 99df3e204f chore(stable): release 3.46.10 (#1332)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-09-07 23:03:37 +02:00
Paul Nothaft 95e3af0800 Merge pull request #1327 from PicPeak/fix/sanitize-html-2.17.7-stable
fix(security): bump sanitize-html to 2.17.7 (stable)
2026-09-07 09:18:32 +02:00
Paul Nothaft 8421b7b668 fix(setup): require Node 22.12 for sanitize-html 2026-09-07 09:00:02 +02:00
Paul Nothaft 0f426ef699 fix(security): bump sanitize-html to 2.17.7
Trivy flags the backend image on two sanitize-html advisories, both
fixed upstream:

- CVE-2026-63670 (fixed 2.17.6): a literal solidus after a raw-text end
  tag (`</textarea/>`) is treated as text by htmlparser2 and re-emitted
  unescaped, so disallowed markup passes when textarea or xmp is in
  allowedTags.
- CVE-2026-84371 (fixed 2.17.7): an SVG SMIL animation whose
  attributeName selects href lets the sibling values/from/to/by
  attributes carry URLs past the scheme policy.

2.17.5 -> 2.17.7, exact pin as before. The new version brings its own
htmlparser2 12 / domhandler 6 / domutils 4 / dom-serializer 3 /
entities 8 tree under node_modules/sanitize-html; nothing else in the
lock moves.

That tree is ESM-only, so the backend now needs unflagged require(esm):
Node 20.19+ or 22.12+. The image is node:22-alpine and CI runs 22, but
engines.node still admitted 22.0-22.11, where require('sanitize-html')
throws ERR_REQUIRE_ESM at startup (publicSiteService loads it during
initialisation). engines is now ^20.19.0 || >=22.12.0 and the native
setup script's Node check enforces the same range instead of accepting
any 22.x. On the supported versions the sanitiser behaves identically
to 2.17.5 on the tracker and newsletter fixtures.

Jest 29's CommonJS registry cannot evaluate ESM either, so every suite
importing a route or service that uses the sanitiser would fail at
import. jest.config.js now maps `sanitize-html` to jest.sanitizeHtml.js,
which hands that one module to Node's real loader via
process.getBuiltinModule('module') — a plain require('module') inside
Jest is Jest's wrapper and returns an empty object for this package.
Verified against a real 2.17.7 install: the sanitiser suites and a
settings route suite pass; without the mapper they fail with "Cannot
use import statement outside a module".
2026-09-06 23:06:45 +02:00
Paul Nothaft be243aafe8 chore(stable): release 3.46.9 (#1283)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-09-03 22:26:45 +02:00
Paul Nothaft 3f90221f40 Merge pull request #1281 from PicPeak/fix/security-scan-batch-1-stable
fix(security): batch 1 (stable) — zxcvbn DoS, revocation forgery, unlink traversals, stored Content-Type, edge middleware
2026-09-03 12:36:42 +02:00
Paul Nothaft ed0a8e7656 chore(deps): apply non-breaking npm audit fixes
Stable twin of the main commit: backend qs/body-parser, frontend axios,
dompurify, linkify-it and the transitive set npm audit fix resolves without
a major bump. sanitize-html 2.17.7 (ESM-only parser tree, Jest 29 cannot
load it; the advisory needs svg tags no sanitizer config allows) and the
tiptap / react-router majors are left out, as on main.
2026-09-03 12:15:53 +02:00
Paul Nothaft c89ce8e172 docs: say the upload allow-list covers every path, video extensions must be added
Settings help text for Allowed File Types (EN, DE). The reference page
lives in the docs repository (PicPeak/docs#18).

(cherry picked from commit f3b062a3, locale files only)
2026-09-03 12:15:39 +02:00
Paul Nothaft d81cade7cc fix(security): harden four smaller gallery and contract paths, drop the unmounted photo auth middleware
- the customer contract PDF stream applies assertContractPdfPath like the
  admin and public contract routes
- OG previews fall back to the site card for draft, archived and
  deactivated galleries instead of leaking name, date and welcome message
- video Range requests are validated before the 206 is written; a NaN,
  inverted or out-of-file range now answers 416
- share-token comparisons in gallery resolve/info use the constant-time
  helper share-login already used
- middleware/photoAuth.js and the galleryAuth/photoAuth/verifyGalleryAccess
  exports of middleware/auth.js were unreferenced since the static mounts
  went; the auth.js copy had neither slug binding nor issuer pin, so it is
  removed before anyone mounts it

(cherry picked from commit 835312e8e6)
2026-09-03 12:15:21 +02:00
Paul Nothaft 406c638451 fix(security): stop reflecting submitted values in validation errors everywhere, cap credential lengths, close the login timing oracle
safeValidationErrors moves to utils/routeHelpers and replaces every
res.status(400).json({ errors: errors.array() }) in the routes, so no 400
body carries the submitted value any more (setup, customer auth and
customer change-password were still echoing rejected passwords).

Admin login, gallery verify, customer login/register/reset, customer
change-password and setup now cap username/slug at 255 and passwords at
MAX_PASSWORD_LENGTH at the validator, so an oversized value never reaches
the lockout lookup, bcrypt or the failed-attempt log.

Admin and customer login run one bcrypt compare on every path; the unknown
account branch used to return in microseconds against ~100ms for a wrong
password, which enumerated usernames despite the generic message.

(cherry picked from commit 40a8a9882a)
2026-09-03 12:14:42 +02:00
Paul Nothaft b1369068ae fix(security): close three middleware gaps around the API edge
Stable port of the main commit; the admin-preview and maintenance-gate
items do not exist on this branch.

- the general rate limiter skipped anyone holding any verified JWT; a
  gallery token is minted for free on password-less galleries and slideshow
  links, so that was an unlimited budget for every /api route. Only admin
  sessions skip now
- the 50mb JSON limit is scoped to /api/admin and /api/v1; everything else
  gets 2mb, so an unauthenticated body can no longer stall JSON.parse
- the CSRF Content-Type gate accepted multipart from any origin; cross-site
  form posts are now rejected via Sec-Fetch-Site / Origin, with a Host match
  fallback for same-origin installs that leave FRONTEND_URL unset

(cherry picked from commit 839bf4e4, adapted)
2026-09-03 12:14:02 +02:00
Paul Nothaft a8d57f0d69 fix(security): never serve a photo under its stored MIME, and stop trusting the chunked-upload type
chunked-upload/init stored the client-declared mimeType on the photo row and
the gallery, secure-image and protected-image routes echoed it as
Content-Type, so a JPEG/HTML polyglot declared as text/html rendered inline
on the app origin for every guest. The admin photo route already resolved
the type safely (#908 review); that logic now lives in
utils/photoContentType and every serving route uses it.

The chunked path derives the MIME from the filename extension and requires
that extension to be on the admin allow-list, matching what the multipart
path enforces through its multer fileFilter.

(cherry picked from commit 063977d97d)
2026-09-03 12:12:56 +02:00
Paul Nothaft 882101b586 fix(security): contain logo, favicon and PDF-logo unlinks to their upload directories
Settings > Branding persisted logo_url / favicon_url verbatim and on clear
unlinked path.join(storage, url) behind a startsWith('/uploads/logos/')
check, which '..' segments pass. The business-profile PDF logo did the same
behind a /pdf-logo-\d+\./ marker test, and used absolute values as given.
Either let a settings.edit or settings.banking holder delete any file the
process can reach.

Both now resolve through helpers in utils/safePath that only ever name a
flat leaf inside the fixed directory. The /favicon.ico streamer is narrowed
the same way: it contained to the whole uploads/ root, which also holds
signed contracts and transfer files.

(cherry picked from commit 3e46530072)
2026-09-03 12:12:12 +02:00
Paul Nothaft c6d401685f fix(security): verify the signature before writing a token to the revocation list
revokeToken() base64-decoded the payload without checking the signature and
inserted a row keyed on id-iat-type, the same key isTokenRevoked() matches
for real sessions. The logout endpoints are unauthenticated, so anyone could
forge a payload naming another user's id, type and login second and log them
out remotely; a far-future exp also left rows that cleanup never swept.

Expiry is still ignored so logging out an expired session stays idempotent.

(cherry picked from commit 0ca0e4a922)
2026-09-03 12:12:12 +02:00
Paul Nothaft 6481708def fix(security): stop reflecting submitted passwords in validation errors
Codex review round 2. The 400 I added in the previous commit returned
errors.array() verbatim, and express-validator puts the submitted `value` in
each error -- so rejecting an oversized password echoed that password back, and
re-allocated up to the 50mb body limit on an unauthenticated endpoint, partly
undoing the denial-of-service fix this branch exists for.

The same call appeared at seven sites in this file, five of which validate a
password field: /admin/login, /gallery/verify, /gallery/:slug/client-login,
/admin/change-password and /password-strength. Every failed login was returning
the attempted password in its response body, where it reaches proxy logs, error
monitoring and browser tooling. Fixed at all seven rather than only the one the
review pointed at.

Only `value` is dropped. `msg`, `path` and the rest are kept, because the two
shapes express-validator produces are both consumed in the frontend -- AcceptInvite
reads {field, message} from routeHelpers.validateRequest, EventDetails reads
{msg, path} from raw errors.array() -- and switching auth.js to the helper's
shape would have broken the latter for a reason unrelated to security.

1 more test. Backend suite: 2744 passed.

(cherry picked from commit 903e471753)
2026-09-03 12:12:12 +02:00
Paul Nothaft 706d402c1e fix(security): enforce the strength-endpoint validators, and stop the generator spinning
Codex review round 1 on the batch-1 security fixes. One finding is a
regression this branch introduced.

generateSecurePassword retried by recursing on any candidate validatePassword
rejected. The new 128-character cap makes EVERY candidate invalid once a caller
asks for more than that, so `generateSecurePassword({ length: 129 })` went from
returning a password to unbounded recursion and a stack overflow. It now
refuses an impossible length up front, and the retry is a bounded loop rather
than recursion -- every candidate failing is possible for reasons other than
bad luck (a charset that cannot satisfy the configured policy), and that case
deserves an error someone can act on rather than a blown stack. No caller in
the repo passes a length at all; the hazard was in the exported surface.

The route validators were decorative. POST /api/auth/password-strength never
called validationResult(), so the length bound I added only recorded an error
that nothing read: the oversized body still reached zxcvbn and the endpoint
still answered 200. The cap inside validatePassword() was doing all the work.
Errors are now returned as a 400 before the validator runs, which is what the
previous commit claimed.

Also awaited validatePasswordInContext, which is async. Unawaited, `validation`
was a Promise and every field in the response -- valid, score, errors, feedback
-- came back undefined. Pre-existing, in the lines this change already touches,
and it made the endpoint useless for the real-time validation it exists for.

1 more test. Backend suite: 2742 passed. The 23 eslint errors in server.js are
pre-existing and identical on main.

(cherry picked from commit 054cd6f82f)
2026-09-03 12:12:12 +02:00
Paul Nothaft ed08ff84ff fix(security): bound password input before zxcvbn, and drop the legacy media mounts
Two findings from the GHSA-pwx6-5pqc-c5xq scan bundle, both verified against
the code and reproduced before fixing.

**Unauthenticated denial of service via password strength (csf_495d53fa).**
POST /api/auth/password-strength takes `body('password').notEmpty()` with no
upper bound, sits behind express.json({ limit: '50mb' }), and hands the string
to zxcvbn, whose matching is superlinear and runs synchronously on the event
loop. Measured on this codebase, in ms of blocked loop: 128 -> 41, 512 -> 1367,
1000 -> 5097, 5000 -> did not return in two minutes. One unauthenticated
request of about a kilobyte stops the whole process for five seconds; a few
kilobytes stops it indefinitely. The /api/auth rate limit does not help when a
single request is already enough.

The cap lives in validatePassword() so it covers every caller, present and
future; the route validator is defence in depth. 128 keeps the worst case at
the cost of an ordinary request while staying far above any real password --
bcrypt consumes only the first 72 bytes, so length past that adds no entropy to
the stored hash anyway. This is the only unauthenticated reach into zxcvbn:
setup is token-gated and self-closing, and acceptInvite/adminAuth use the
regex-only validator in passwordGenerator.

**The /photos and /thumbnails static mounts (csf_9aa6afe6, csf_559cd5cc,
csf_b14d462e, csf_547d26fa, and the gallery half of csf_34e420af).**
They served the raw originals and thumbnail trees behind photoAuth, which
authorises on a slug match. A static file server cannot apply per-photo rules,
so everything the gallery API decides was absent: allow_downloads, per-category
allow_downloads, watermarking, the resolution cap, reveal-mode windows,
visibility='hidden', download logging, and the customer-assignment re-check
that makes revocation immediate. photoAuth also bcrypt-compares an
x-gallery-password header per request with no limiter -- both rate-limit gates
return early for non-/api paths -- so the mount was an unmetered password
oracle. The filenames needed to drive all of this are handed to every guest in
the photos listing.

Nothing builds these URLs: no reference in frontend/src, none in the email
templates, and the only backend mentions are the /api/admin/photos/... API
routes and a maintenance-mode prefix list. The equivalent authorised routes are
/api/gallery/:slug/photo/:id and /thumbnail/:id. nginx still proxies the two
locations; they now 404, which is the intent.

**The /uploads mount (csf_1fc92f57).** It exposed the whole uploads/ root with
no auth middleware at all, and that root also holds signed contract PDFs
(uploads/contracts/signed) and client transfer files (uploads/transfers/<id>),
reachable by anyone who learned or guessed a filename. Narrowed to the two
public asset trees it exists for; contracts and transfers keep their own
authorised routes.

Removing the mounts leaves src/middleware/photoAuth.js unreferenced by
application code. Left in place deliberately -- deleting it and its tests is a
separate cleanup, and a smaller diff backports more safely.

Backend suite: 2742 passed.

(cherry picked from commit 14cd5eacb3)
2026-09-03 12:12:06 +02:00
Paul Nothaft 7fe80220f1 chore(stable): release 3.46.8 (#1250)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-09-01 22:29:02 +02:00
Paul Nothaft ccdcdd6116 chore: keep issue screenshots out of the source tree (#1260)
Preventative twin of the main-side cleanup. This branch has no stray images
to remove — it just gets the same guard, so the two branches agree and a
backport cannot carry one across.

Two PR screenshots landed at main's repo root in #1241 and shipped as part of
the source tree. Screenshots belong on a `screenshots/*` branch, which is how
every other UI change here has attached its evidence.

Anchored with a leading slash so docs/ keeps its own images and test-assets/
keeps the fixtures the e2e specs load. Verified no tracked file on this branch
matches the new patterns.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-01 09:34:55 +02:00
Paul Nothaft ccc725f36e fix(upload): let Android guests reach the camera without breaking video (#1248)
* fix(upload): let Android guests reach the camera without breaking video

Stable twin of #1244 (which replaces #1117). The reporter is on 3.46.1, so
this branch is where the bug is actually being hit.

Recent Android versions route an <input> whose accept list is entirely
image/video types to the system photo picker, which has no camera entry —
so a guest standing at the event can only pick an existing photo, not take
one. Including a type that picker can't handle forces the general chooser,
which does offer the camera.

Gated on the Android UA: iOS and desktop pickers behave correctly and would
only gain a selectable PDF that addFiles then rejects. No image-only guard
— #1117 added one that broke video uploads outright on any install
configured for them, and it was redundant anyway, since
extensionsToMimeTypes only emits types it has a mapping for and the
existing allowlist check already rejects a picked PDF.

The premise — that this actually surfaces the camera option on Android — is
taken at the reporter's description level and still needs confirmation on a
device.

Co-authored-by: Zszywany <Zszywany@users.noreply.github.com>

* fix(upload): use android/allowCamera instead of .pdf for the chooser fallback

Same mechanism, better token. Chrome on Android 14/15 sends an input whose
accept list is all media types to the photo picker, which has no camera tile;
adding a value that picker cannot satisfy makes it fall back to the general
chooser, which does offer the camera.

`.pdf` achieves that but advertises PDFs as selectable — pick one and the
existing allowlist check answers "Invalid file type", which is a dead end we
put in front of the guest ourselves. `android/allowCamera` is the token the
workaround converged on: not a real MIME type, matches no file, so it flips
the picker without offering anything.

Neither token ever widened what is accepted — addFiles validates against
extensionsToMimeTypes, which only emits types it has a mapping for — but not
showing the guest a choice that cannot work is worth the one-line change.

Verified in a browser rather than asserted: the real component rendered under
an Android UA emits

  image/jpeg,image/png,image/webp,android/allowCamera

and under a desktop UA

  image/jpeg,image/png,image/webp

with the visible modal identical in both, and the format hint still reading
"JPG, JPEG, PNG, WEBP" — the token does not leak into anything a guest sees.

* fix(upload): keep the camera token off Firefox for Android

External review round. The gate was a bare /Android/i, which Firefox for
Android matches — so it received a token invented to reroute Chromium's photo
picker, a picker it does not use. The doc comment two lines up already said
Firefox behaves correctly; the code did not agree with it.

Inert at best, and at worst it perturbs a chooser that was working. Narrowed
to Android minus Firefox, which is the Chromium-family set the behaviour was
actually observed on (Chrome and Edge, Android 14/15), with a UA test to pin
it.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: Zszywany <Zszywany@users.noreply.github.com>
2026-09-01 09:24:38 +02:00
Paul Nothaft fed99ac03d fix(archives): write a real timestamp on restored photos (#1257)
Brings this branch in line with main, which fixed it in passing.

The archive restore inserted photos with a bare Date for uploaded_at. Inside
jest the sqlite3 binding's type dispatch misses sandbox-created Dates and
stores the literal string "[object Object]", so every restored photo got a
garbage timestamp. Verified on this branch rather than assumed:

  bare Date   -> "[object Object]"
  toISOString -> "2026-09-01T06:48:41.915Z"

Production writes Dates as ms-numbers and is unaffected, which is exactly why
it survives unnoticed — it only corrupts what tests read back, so a future
test asserting on a restored photo's date would have believed it.

The regression test fails against the previous line.

Not touched: the category insert a few lines up has the same shape, but it is
identical on main, so fixing it here alone would re-open the divergence this
commit closes. Worth one small PR against both branches.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-01 08:52:35 +02:00
Paul Nothaft a01731d986 chore: remove .pyc files committed to stable by mistake (#1256)
My doing, in #1247: I built that stable twin in a working tree that still
held untracked bytecode from main's ML sidecar, and a `git add -A` swept 16
.pyc files in alongside the two real ones.

Stable never carried the ignore rule because the sidecar itself is main-only
— which is precisely why nothing stopped it here. Added, so a shared working
tree cannot repeat it.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-01 08:41:37 +02:00
Paul Nothaft 1d9f0b6c64 fix(events): apply the gallery password policy to publish (#1255)
Stable twin of #1253. This branch has only the publish door —
send-gallery-email is #1235, main-only — so the same gap exists here in one
place rather than two.

/publish re-hashes password_hash from a plaintext the admin re-types in the
publish dialog, validated with nothing but express-validator's
isLength({min:6}). So the configured complexity — moderate by default,
meaning 8 characters plus upper, lower and a digit — governed event creation
and password reset while this door accepted 'aaaaaa' and made it the live
gallery password.

Not an escalation: it needs admin auth plus events.edit. It is a policy gap,
the admin UI advertising a complexity level this write path did not enforce.

BEHAVIOUR CHANGE: an API-only consumer publishing with a sub-policy password
now gets 400, with the same body shape event creation returns (error,
details, score, feedback).

3 tests, including that the rejection happens BEFORE the write — the gallery
keeps its old hash and stays a draft — and that a publish carrying no
password at all is untouched.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-09-01 08:36:41 +02:00
Paul Nothaft 261e243070 fix(archives): take the restored category from the manifest (#1240) (stable) (#1243)
* fix(archives): take the restored category from the manifest (#1240) (stable)

Stable twin of #1240. The reporter hit this on 3.46.7 — stable — with 596
photos restored and 0 categories, so this is the branch the bug was actually
found on.

Stable carries the manifest on both sides already: archiveService selects
`photo_categories.name as category_name` and serialises it, and the restore
route builds manifestByFilename. It just never read the category out of it,
deriving one from the ZIP's first path segment instead. Archives store photos
as they sit on disk, so an event whose photos live in the gallery root
produces a flat zip, no category resolves, and every photo comes back with
category_id null — silently, behind a 200.

Carries the whole of #1240, not a subset: the manifest-first resolution and
the shared resolveCategoryId from Marian's commit, plus the follow-up that
makes the manifest authoritative when it says "no category" — an entry with a
null category_name is a photo that was genuinely uncategorized, and falling
through to the directory contradicted the record being restored from. That
matters because the directory is not a category: entry names are the storage
key minus events/active/{slug}, so a real archive yields `individual/` and
`collages/`, and reading the first segment invents categories with those
names.

Re-verified on stable rather than assumed: all four tests pass here, and three
of them fail against stable's current route, with the legacy no-manifest
fallback passing either way. The two changed files are byte-identical to main.

Co-authored-by: Marian <lippitz.marian@yahoo.de>

* fix(archives): keep the stable twin to stable's schema, and close two category holes

External review on #1243 caught that this twin was ported wrong and that the
category resolver has two holes the main PR shares.

PORTED WRONG. I took main's whole adminArchives.js rather than applying the
category change to stable's, which dragged in main-only face cleanup:
photo_faces and event_people have migrations on main and none on stable, so
every permanent archive deletion would have thrown a missing-table error —
after the ZIP was already unlinked, leaving the event archived with its
archive gone and a 500 back. Rebuilt from stable's file with only the category
change; the diff against stable is now the fix and nothing else.

GLOBAL CATEGORIES WERE CLONED. Seeded categories (Ceremony, Reception) have
event_id NULL, so an event-only lookup missed them and created a second row —
and is_global defaults to TRUE, so that duplicate then appeared in every other
event's category list. The lookup now uses the same visibility rule the photo
routes use (own rows OR global), and anything it does create is explicitly
is_global false.

ORIGINAL-FILENAME ARCHIVES MATCHED NOTHING. With
general_use_original_filenames_for_downloads on at archive time, archiveService
names each ZIP entry after the original filename while the manifest stays keyed
by photos.filename — so the lookup missed every entry and those archives lost
categories exactly as before the fix. The manifest is now indexed by
original_filename as well, without letting it shadow a real filename key.

7 tests, three of them new; each new one fails against the un-fixed route and
the legacy no-manifest fallback passes throughout.

* fix(archives): sanitized original names and deterministic category scope

Round 2 of external review on #1243.

The original_filename index used the raw column, but archiveService runs the
name through sanitizeForZipEntry() before writing the entry — so an original
containing a slash or control byte was emitted under a different name than the
manifest records, and the lookup missed it. Both spellings are indexed now,
using the same helper the writer uses.

Not total, and the comment says so: uniquifyZipNames() appends `_1` when two
photos in one event share an original name, and that suffix cannot be
reconstructed from the manifest. Those fall through to the directory exactly
as they did before this fix — no worse, just not better. Closing it needs the
emitted name recorded at archive time, which is a writer change and a new
archive format.

The category lookup used one OR-query with .first(). An event-scoped category
and a global one may share a name — the category API permits it — so the
engine picked whichever, and a photo could be silently reassigned to the
global row, losing event-local settings like allow_downloads. Two queries now,
event-scoped first: the event's own row is the more specific answer.

9 tests, two new; both fail against the un-fixed route.

* fix(archives): don't adopt another event's legacy row, don't guess an alias

Round 3 of external review on #1243.

The global fallback matched on is_global alone. The very bug fixed here left
rows behind on upgraded instances — event-owned AND is_global true, because
the column defaults true — so restoring event B could adopt event A's
leftover, tying B's photos to a category that disappears when A is deleted.
The fallback now requires event_id IS NULL: genuinely global, not merely
flagged.

The original-filename alias map collapsed rows that share a basename.
archiveService treats `individual/IMG.jpg` and `collages/IMG.jpg` as distinct
paths and suffixes neither, so both manifest rows claimed one alias and
whichever won handed the other photo someone else's category. An alias claimed
by more than one row is now dropped and logged, so those photos fall back to
the directory: an unresolved category is recoverable, a confidently wrong one
is not.

11 tests, two new; both fail against the un-fixed route.

* fix(archives): make the manifest lookup order-independent and collision-safe

Two bugs found by an external review round, both in the manifest index.

The canonical map silently kept the last row for a duplicated
photos.filename. That column is not unique within an event — s3AutoImporter
takes path.basename(entry.key) and dedupes by path, so two imported files in
different subfolders both land as IMG_1234.jpg with different paths. At
restore both ZIP entries reduce to the same basename, so one photo got the
other's category. Contested names are dropped now, like ambiguous aliases
already were.

The alias pass could also evict a canonical key: when one row's
original_filename equalled another row's filename, the collision was marked
ambiguous and the sweep deleted the canonical entry. The comment two lines
above says a real filename key is authoritative and must never be
overwritten — the code did the opposite, and which way it went depended on
manifest iteration order, since the archive query has no ORDER BY.

Split into two passes so canonical names are claimed first and aliases only
fill names no canonical row wanted.

* fix(archives): treat a canonical/alias name clash as ambiguous, resolve categories lazily

Round-2 findings, one of which corrects my own round-1 fix.

Round 1 made a canonical filename outrank any alias. That is the wrong
tiebreak: when photo A's filename equals photo B's original_filename, which
file the ZIP actually emitted under that name depends on whether
original-filename archiving was on at archive time — with it ON the entry is
B's, with it OFF it is A's — and the manifest does not record the mode.
Preferring either silently mislabels the other half of the time, so the name
is dropped and both fall through to the directory. What the two-pass split
still buys is determinism: the archive query has no ORDER BY, so this used to
be a coin flip between dropping the name and overwriting it.

Categories are resolved inside the !existingPhoto branch. resolveCategoryId
find-or-CREATES, and archiveEvent retains photo rows, so restoring an archive
whose rows still exist created a category from the stale manifest name that
nothing then used — renaming a category while its event was archived left the
old name behind as an empty duplicate.

Not fixed: two event-scoped categories may share a display name with distinct
slugs, and the .first() lookup then picks either row, so manifest entries from
both collapse onto one id and can inherit the wrong allow_downloads. Detecting
it is easy; resolving it correctly needs a stable category identifier in the
manifest, which is a writer change and an archive-format bump.

* fix(archives): make a duplicate category name deterministic, and log it

Round-2 finding. Two event-scoped categories may share a display name when
their slugs differ, and the .first() lookup then picked one arbitrarily —
manifest entries for both collapsed onto a single id and half the photos
inherited the wrong per-category settings, allow_downloads above all.

Fixing it properly needs a stable category identifier in the manifest: a
writer change, an archive-format bump, and no help at all for archives
already written. Not worth building before knowing it happens. So the
collision is surfaced instead — a warning naming the category and the row
count — and the tiebreak is made deterministic (lowest id) so at least a
re-run lands the same way twice.

If this never fires in real logs, the format change was not worth making. If
it does, this is the evidence for it.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: Marian <lippitz.marian@yahoo.de>
2026-09-01 08:17:48 +02:00
Paul Nothaft 5470fbe406 fix(gallery): route single-photo downloads through the storage backend (#1246)
* fix(gallery): route single-photo downloads through the storage backend

Stable twin of #1048. The route resolved a local filesystem path
unconditionally and handed it to res.sendFile. On an S3/R2 deployment
managed photos are never on local disk, so every per-photo download failed
— while download-all and secure-images worked, because they already went
through getStorage(). That asymmetry is why it went unnoticed: the gallery
looks healthy until a guest clicks the download button on one photo.

Because sendFile is called WITH a callback, Express does not send a
response when the file is missing and the callback only logs — the request
does not 404, it hangs until the client gives up. The new tests pin this:
all five backend-path cases time out against the current implementation.

- watermark branch: materialize a tmp local copy via withLocalCopy in S3
  mode and hand applyWatermark the copy's PATH, so its path-keyed cache
  still applies. Same pattern the zip builders in this file already use.
- pass-through branch: local disk keeps res.sendFile, which emits
  Content-Length, Accept-Ranges, ETag and Last-Modified and answers Range
  with a 206. Sharing one bare stream.pipe(res) with S3 would silently drop
  all of it, and a resumed download would append a second full body onto
  the partial file. On S3 the parts that matter are reproduced via stat()
  and getRange().
- external/reference photos keep the local-path fallback unchanged —
  resolvePhotoStorageKey returns null for them.

Ranges are parsed defensively: an unchecked parse yields NaN bounds and a
206 with a nonsense Content-Range, which corrupts a resumed download rather
than failing it. Malformed or unsatisfiable ranges fall back to a 200.

Written against stable's shape rather than cherry-picked — main's version
delegates to renderPhotoForDownload (#858), which does not exist here.

Co-authored-by: Peifu Mo <peipeimo@users.noreply.github.com>

* fix(gallery): open the stream before staging download headers, honour If-Range

Both from an external review round on #1048.

stat() succeeding does not mean get() will — a concurrent delete or replace,
or a transient backend error, lands between them. The fetch was awaited
AFTER the headers went out, so the range branch had already called
writeHead(206) and the outer catch could only throw ERR_HTTP_HEADERS_SENT
(in practice the request hangs), while the full branch would have sent its
500 JSON underneath the staged image/jpeg attachment headers.

Opening the stream first also lets a vanished object answer 404 and a
transient failure answer 500.

If-Range: emitting Last-Modified without honouring the validator built from
it is the dangerous half. A client resuming after the object was replaced
would get 206 from the NEW bytes and splice two versions into one corrupt
file. A non-matching validator now falls back to a full 200.

* fix(gallery): HEAD without egress, classify render failures, stage 206 headers

Round-2 findings from the external reviewer on #1048, ported.

Express routes HEAD through this GET handler and Node discards the body, but
the pipe still drains the whole object out of S3 first — a metadata probe
cost a full transfer in egress and latency. Everything a HEAD needs is
already in stat().

The watermark branch reported every failure as 404. It can equally fail
because getToFile timed out, tmp filled up, or sharp died; calling that
"photo not found" misleads the guest and hides the incident.

The 206 path uses status()+set() instead of writeHead(), which commits
immediately and left a stream erroring at byte zero with no outcome but a
destroyed connection. Staged headers flush on first write, so that case now
returns a clean retryable status.

pipeStreamToResponse also cleared Content-Type, Content-Length, ETag and
Content-Disposition but not the range headers, so the 500 went out still
advertising Content-Range — telling a resuming client the error body IS the
partial content.

* fix(gallery): answer HEAD before the counters

Round-3 finding on #1048, ported. The HEAD short-circuit was inside the
storage branch, below both the download_count increment / access_logs insert
and the watermark path — so a download manager's metadata probe counted as a
real download, and on a watermarked gallery it also pulled the original from
S3 and ran sharp over it to build a body Node then discards.

HEAD now leaves right after the access checks. Content-Length is included
only when the photo ships untransformed and the size is readable from stat();
a watermark changes the length and the only way to learn it is to do the work
this branch exists to avoid.

Uses stable's inline watermark resolution — resolveWatermarkSettings comes
from downloadRendition (#858), which does not exist on this branch.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: Peifu Mo <peipeimo@users.noreply.github.com>
2026-09-01 08:17:19 +02:00
Paul Nothaft 7102687ee8 fix(events): delete stored objects when cascading an event delete (#1245)
* fix(events): delete stored objects when cascading an event delete

Stable twin of #1051. Deleting an event removed its database rows but left
every stored file behind: deleteEventCascade() cleaned up with fs.rm over
{STORAGE_PATH}/events/{active,archived}/{slug}, and on an S3-compatible
backend those paths don't exist locally — the call succeeds against nothing
and the real objects stay in the bucket, unreferenced by any row, invisible
in the UI, and billed every month.

Measured on a v3.45.16 install against Cloudflare R2, deleting one
403-photo event: bucket object count 5,400 before and 5,400 after, while
referenced rows dropped from 3,425 to 2,746.

Keys are collected BEFORE the transaction removes the photo rows — once
they are gone nothing records which objects belonged to the event, and only
a full-bucket audit against the whole database could find them again — and
deleted AFTER the commit, so a rolled-back delete can never destroy files
for an event that still exists.

Includes photo.watermark_path and event.archive_path, both storage-backed
and both previously fs.unlink-only. event.hero_logo_path is deliberately
excluded: multer writes logos to local disk with diskStorage regardless of
backend, so they are never bucket objects.

Reference/external photos are left alone — resolvePhotoStorageKey returns
null for them and PicPeak does not own those bytes.

This branch carries the higher priority of the pair: unlike main, stable's
deleteEventCascade never calls getStorage() at all, and the leak costs real
money for every month it goes unfixed.

Co-authored-by: Peifu Mo <peipeimo@users.noreply.github.com>

* fix(events): sweep the Download All cache, and delete objects concurrently

Both from an external review round on #1051.

The pre-built "Download All" zip (events.download_zip_path) lives under
events/active/{slug}/.download-cache/. On local disk the recursive fs.rm
already covered it, which is exactly why it was easy to miss — on S3 that
prefix is not a directory, nothing covered it, and it is gallery-sized.
downloadZipService exposes a cleanup() documented as "used on event
deletion" that the cascade never called.

download_jobs (main's #173) does not exist on this branch, so the
per-job archives main also sweeps have no counterpart here.

Deletes now run through a bounded pool instead of one await per key: a
400-photo gallery owns well over a thousand objects, and that many
sequential DeleteObject round trips runs to minutes — long enough for a
proxy to time the request out AFTER the commit, leaving the event deleted
and the sweep half-finished.

* fix(events): never delete a derivative another gallery still uses

Round-2 findings from the external reviewer on #1051, ported.

Canonical thumbnail/hero/preview keys are not event-scoped: the basename is
the photo's filename, and filenames are not unique across events. A legacy
gallery can share a canonical derivative with a photo in another event, and
deleting it here blanked a surviving gallery's tile. Derived keys are now
checked against photos outside this event and anything still referenced is
left alone; if the check itself fails, every derivative is kept — an orphan
costs storage, a deleted derivative costs someone else's gallery. Originals
need no check, their keys embed the slug.

Also cancel any in-flight or debounced Download All build before snapshotting
paths, via downloadZipService.cleanup() — the service's own entry point for
event deletion. A builder that started before the delete would otherwise
upload a gallery-sized zip after the sweep and write its path onto a row that
no longer exists.

* revert(events): drop the Download All build cancellation

It broke CI on this branch: the backend job went from ~2 minutes to
exceeding its 10-minute budget, twice, reproducibly.

downloadZipService.cleanup() reaches getStorage() through _cleanup(), and in
a suite where the S3 backend is configured but unreachable every cascade
delete then pays the adapter's retry backoff. The full suite passes locally
against SQLite, which is why this only showed up in CI.

The race it addressed is real but narrow — a builder that started before the
delete uploads its zip after the sweep and writes the path onto a row that
no longer exists, orphaning one object. That is a cheaper problem than an
unrunnable test suite, so it goes back to being a documented follow-up
rather than shipping behind a timeout.

The shared-derivative guard from the same review round stays: that one
prevented deleting a surviving gallery's thumbnail.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: Peifu Mo <peipeimo@users.noreply.github.com>
2026-09-01 08:16:51 +02:00
Paul Nothaft 5b69e3ec4c fix(auth): treat zxcvbn suggestions as advice, not blocking errors (#1247)
Stable twin of #1050. passwordValidation.js is byte-identical between the
branches, so this is the same change verbatim.

validatePassword() appended zxcvbn's feedback.suggestions to the errors
array unconditionally, and validity is errors.length === 0 — so any
password that merely earned a suggestion was rejected even when it
satisfied every configured rule. The effective policy was stricter than
the configured complexity level and invisible to the admin.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: Peifu Mo <peipeimo@users.noreply.github.com>
2026-09-01 08:16:33 +02:00
Paul Nothaft eebca9900b fix(auth): treat zxcvbn suggestions as advice, not blocking errors (#1247)
Stable twin of #1050. passwordValidation.js is byte-identical between the
branches, so this is the same change verbatim.

validatePassword() appended zxcvbn's feedback.suggestions to the errors
array unconditionally, and validity is errors.length === 0 — so any
password that merely earned a suggestion was rejected even when it
satisfied every configured rule. The effective policy was stricter than
the configured complexity level and invisible to the admin.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Co-authored-by: Peifu Mo <peipeimo@users.noreply.github.com>
2026-09-01 08:05:57 +02:00
87 changed files with 4330 additions and 1254 deletions
+19
View File
@@ -135,3 +135,22 @@ new-layouts/
# writes event photos into backend/storage/, and the narrower
# business-docs-only rule let `git add -A` sweep them into a commit.
backend/storage/
# Python bytecode. The ML sidecar lives on main only, so this branch never
# needed the rule — which is how a `git add -A` from a shared working tree
# committed 16 .pyc files here in #1247.
__pycache__/
*.pyc
# Issue / PR screenshots belong on a `screenshots/*` branch, never on main or
# stable — that is what those branches exist for. Two landed at the repo root
# on main in #1241 and shipped as part of the source tree; nothing stopped it.
#
# Anchored with a leading slash so docs/ keeps its own images.
/issue-*.png
/issue-*.jpg
/screenshot-*.png
/screenshot-*.jpg
/*-screenshot.png
/*-screenshot.jpg
+1 -1
View File
@@ -1 +1 @@
{".":"3.46.7"}
{".":"3.46.11"}
+51
View File
@@ -5,6 +5,57 @@ 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.46.11](https://github.com/PicPeak/picpeak/compare/v3.46.10...v3.46.11) (2026-09-08)
### Documentation
* align stable security and backport policy ([#1352](https://github.com/PicPeak/picpeak/issues/1352)) ([143c403](https://github.com/PicPeak/picpeak/commit/143c4035ec38683634d0e3d493032e2965f4a46f))
## [3.46.10](https://github.com/PicPeak/picpeak/compare/v3.46.9...v3.46.10) (2026-09-07)
### Bug Fixes
* **security:** bump sanitize-html to 2.17.7 ([0f426ef](https://github.com/PicPeak/picpeak/commit/0f426ef69968b395c6e3fbd0301fe3a7759a5f44))
* **security:** bump sanitize-html to 2.17.7 (stable) ([95e3af0](https://github.com/PicPeak/picpeak/commit/95e3af080039f2d31e1cb9c85a3d93b22c80ba7c))
* **setup:** require Node 22.12 for sanitize-html ([8421b7b](https://github.com/PicPeak/picpeak/commit/8421b7b668484f87cd2bacda8fb4d95a3bc07ab5))
## [3.46.9](https://github.com/PicPeak/picpeak/compare/v3.46.8...v3.46.9) (2026-09-03)
### Bug Fixes
* **security:** batch 1 (stable) — zxcvbn DoS, revocation forgery, unlink traversals, stored Content-Type, edge middleware ([3f90221](https://github.com/PicPeak/picpeak/commit/3f90221f40b604dd5cebc016aad9478e84035b97))
* **security:** bound password input before zxcvbn, and drop the legacy media mounts ([ed08ff8](https://github.com/PicPeak/picpeak/commit/ed08ff84ff014226f1e17cc17167f80afa366f7c))
* **security:** close three middleware gaps around the API edge ([b136906](https://github.com/PicPeak/picpeak/commit/b1369068ae1327fc29e8aa671029548e2e93d827))
* **security:** contain logo, favicon and PDF-logo unlinks to their upload directories ([882101b](https://github.com/PicPeak/picpeak/commit/882101b58670e99ac3aea560b83fc4123fe4b359))
* **security:** enforce the strength-endpoint validators, and stop the generator spinning ([706d402](https://github.com/PicPeak/picpeak/commit/706d402c1e979d8419396c451487fb9be756a449))
* **security:** harden four smaller gallery and contract paths, drop the unmounted photo auth middleware ([d81cade](https://github.com/PicPeak/picpeak/commit/d81cade7cc9a39179b05ace5ae47b13bbe1d8196))
* **security:** never serve a photo under its stored MIME, and stop trusting the chunked-upload type ([a8d57f0](https://github.com/PicPeak/picpeak/commit/a8d57f0d696b9e0e92d6ae91beff9f3ad0fa1695))
* **security:** stop reflecting submitted passwords in validation errors ([6481708](https://github.com/PicPeak/picpeak/commit/6481708def49bc9cdf424752a633e320813cf280))
* **security:** stop reflecting submitted values in validation errors everywhere, cap credential lengths, close the login timing oracle ([406c638](https://github.com/PicPeak/picpeak/commit/406c6384513da2d7582dc223d799fba7cad56745))
* **security:** verify the signature before writing a token to the revocation list ([c6d4016](https://github.com/PicPeak/picpeak/commit/c6d401685f4eb4d9fd5fb70636962d73fc631cde))
### Documentation
* say the upload allow-list covers every path, video extensions must be added ([c89ce8e](https://github.com/PicPeak/picpeak/commit/c89ce8e1721adfd67598cf35ae307e97ed185827))
## [3.46.8](https://github.com/PicPeak/picpeak/compare/v3.46.7...v3.46.8) (2026-09-01)
### Bug Fixes
* **archives:** take the restored category from the manifest ([#1240](https://github.com/PicPeak/picpeak/issues/1240)) (stable) ([#1243](https://github.com/PicPeak/picpeak/issues/1243)) ([261e243](https://github.com/PicPeak/picpeak/commit/261e243070b6082ccd8b972de68d2fee15329235))
* **archives:** write a real timestamp on restored photos ([#1257](https://github.com/PicPeak/picpeak/issues/1257)) ([fed99ac](https://github.com/PicPeak/picpeak/commit/fed99ac03dfde03cf4c55fcb4b1419fea7564156))
* **auth:** treat zxcvbn suggestions as advice, not blocking errors ([#1247](https://github.com/PicPeak/picpeak/issues/1247)) ([5b69e3e](https://github.com/PicPeak/picpeak/commit/5b69e3ec4c898204c9fcde0f1d49f24edc688891))
* **auth:** treat zxcvbn suggestions as advice, not blocking errors ([#1247](https://github.com/PicPeak/picpeak/issues/1247)) ([eebca99](https://github.com/PicPeak/picpeak/commit/eebca9900b6f00b222eec16e27fa6be4fe2ce9fa))
* **events:** apply the gallery password policy to publish ([#1255](https://github.com/PicPeak/picpeak/issues/1255)) ([1d9f0b6](https://github.com/PicPeak/picpeak/commit/1d9f0b6c6491cface22f12651131fd5dfba330f0))
* **events:** delete stored objects when cascading an event delete ([#1245](https://github.com/PicPeak/picpeak/issues/1245)) ([7102687](https://github.com/PicPeak/picpeak/commit/7102687ee804140bfaca420d2eb7ec0078e50f25))
* **gallery:** route single-photo downloads through the storage backend ([#1246](https://github.com/PicPeak/picpeak/issues/1246)) ([5470fbe](https://github.com/PicPeak/picpeak/commit/5470fbe4063c3d6c0aeeb50fdb1ce6af74df1b53))
* **upload:** let Android guests reach the camera without breaking video ([#1248](https://github.com/PicPeak/picpeak/issues/1248)) ([ccc725f](https://github.com/PicPeak/picpeak/commit/ccc725f36edcf20643ab9c3a7aff16b5b93674c1))
## [3.46.7](https://github.com/PicPeak/picpeak/compare/v3.46.6...v3.46.7) (2026-08-28)
+5 -4
View File
@@ -163,13 +163,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.
@@ -187,6 +188,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! 🎉
+6 -1
View File
@@ -62,13 +62,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).
@@ -0,0 +1,498 @@
/**
* Restoring an archive must put the photos back into their categories.
*
* The archive writer already persists `category_name` per photo in
* `photos_manifest.json` — that is why the manifest exists, and the comment
* above it says so: "(and category linkage) can't be derived from the
* extracted files alone". The restore route then read only
* `original_filename` from it and kept deriving the category from the ZIP's
* first path segment.
*
* Archives store photos exactly as they sit on disk, so an event whose photos
* live in the gallery root produces a FLAT zip. `path.dirname()` is '.' for
* every entry, no category is resolved, and every restored photo lands with
* `category_id = null` — silently, with a 200 response.
*
* These pin the manifest as the source of truth, with the directory as the
* fallback that keeps foldered and legacy archives working.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('archive restore restores categories (flat archives included)', () => {
let tmpDir; let db; let cleanup; let app; let storagePath;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-restore-cat-'));
storagePath = path.join(tmpDir, 'storage');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
process.env.STORAGE_PATH = storagePath;
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
// bootCrmDb points STORAGE_PATH at its own tmp dir; follow it rather than
// fighting it, so the archives the tests write are where the route looks.
storagePath = process.env.STORAGE_PATH;
await fs.promises.mkdir(path.join(storagePath, 'archives'), { recursive: true });
app = express();
app.use(express.json());
app.use('/admin/archives', require('../../src/routes/adminArchives'));
}, 180000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
await db('photos').del();
await db('photo_categories').del();
await db('events').del();
});
/** A one-pixel JPEG is enough; the route only stats the extracted file. */
const PIXEL = Buffer.from(
'/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0a'
+ 'HBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/wAALCAABAAEBAREA/8QAFAABAAAAAAAA'
+ 'AAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAD8AKp//2Q==',
'base64',
);
async function writeArchive(name, entries) {
// Required lazily: the suite calls jest.resetModules() in beforeAll, and
// archiver's readable-stream copy does not survive being split across the
// two module registries.
const archiver = require('archiver');
const archivePath = path.join(storagePath, 'archives', name);
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(archivePath);
const zip = archiver('zip', { zlib: { level: 0 } });
output.on('close', resolve);
zip.on('error', reject);
zip.pipe(output);
for (const [entryName, buffer] of Object.entries(entries)) {
zip.append(buffer, { name: entryName });
}
zip.finalize();
});
return path.join('archives', name);
}
async function seedArchivedEvent(archiveRelPath, slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-06-27',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `${slug}-share`,
expires_at: new Date().toISOString(),
is_archived: 1, // sqlite stores booleans as 0/1, see utils/dbCompat
archive_path: archiveRelPath,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
const categoryOf = async (filename) => {
const photo = await db('photos').where('filename', filename).first();
if (!photo || !photo.category_id) return null;
const category = await db('photo_categories').where('id', photo.category_id).first();
return category ? category.name : null;
};
it('takes the category from the manifest when the archive is flat', async () => {
// Exactly the shape a gallery-root event archives to: no directories.
const manifest = JSON.stringify([
{ filename: 'a.jpg', original_filename: 'DSC_0001.jpg', category_name: 'Polterabend' },
{ filename: 'b.jpg', original_filename: 'DSC_0002.jpg', category_name: 'Ceremony' },
]);
const archiveRelPath = await writeArchive('flat.zip', {
'a.jpg': PIXEL,
'b.jpg': PIXEL,
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'flat-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
// The whole bug: both of these used to be null.
expect(await categoryOf('a.jpg')).toBe('Polterabend');
expect(await categoryOf('b.jpg')).toBe('Ceremony');
});
it('stores a real timestamp on restored photos, not "[object Object]"', async () => {
// The jest+sqlite landmine: a Date handed to knex inside jest stores as
// the literal string "[object Object]". Production writes ms-numbers and
// is unaffected, so this only ever corrupts what tests read back — which
// is how it survives unnoticed.
const archiveRelPath = await writeArchive('timestamp.zip', {
'individual/STAMPED.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'STAMPED.jpg', original_filename: 'STAMPED.jpg', category_name: 'Ceremony' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'timestamp-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
const photo = await db('photos').where({ event_id: eventId, filename: 'STAMPED.jpg' }).first();
expect(String(photo.uploaded_at)).not.toBe('[object Object]');
expect(Number.isNaN(new Date(photo.uploaded_at).getTime())).toBe(false);
});
it('reuses an existing category row instead of creating a duplicate', async () => {
const archiveRelPath = await writeArchive('reuse.zip', {
'c.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'c.jpg', original_filename: 'DSC_0003.jpg', category_name: 'Party' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'reuse-event');
await db('photo_categories').insert({
event_id: eventId, name: 'Party', slug: 'party', created_at: new Date(),
});
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('c.jpg')).toBe('Party');
const rows = await db('photo_categories').where({ event_id: eventId, name: 'Party' });
expect(rows).toHaveLength(1);
});
it('still falls back to the directory for legacy archives with no manifest', async () => {
// No manifest at all — the shape every archive had before the manifest
// landed. The directory is the only signal left, and it must keep working.
//
// `individual/` is what a REAL archive contains: entry names are the
// storage key minus `events/active/{slug}`, and that layout is
// `individual/` / `collages/`. Categories have never been directories, so
// the fallback invents a category with that name — not useful, but better
// than losing every category, and this pins what actually happens rather
// than a category-shaped folder no archive produces.
const archiveRelPath = await writeArchive('foldered.zip', {
'individual/d.jpg': PIXEL,
});
const eventId = await seedArchivedEvent(archiveRelPath, 'foldered-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('d.jpg')).toBe('individual');
});
it('reuses a GLOBAL category instead of cloning it into the event', async () => {
// Seeded categories (Ceremony, Reception, ...) have event_id NULL. An
// event-only lookup misses them, so the restore used to create a second
// "Ceremony" — and because is_global defaults to TRUE, that duplicate then
// appeared in every other event's category list.
const [g] = await db('photo_categories').insert({
event_id: null, name: 'Ceremony', slug: 'ceremony', is_global: true, created_at: new Date(),
}).returning('id');
const globalId = typeof g === 'object' ? g.id : g;
const archiveRelPath = await writeArchive('global.zip', {
'individual/gl.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'gl.jpg', original_filename: 'DSC_1.jpg', category_name: 'Ceremony' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'global-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
const photo = await db('photos').where('filename', 'gl.jpg').first();
expect(photo.category_id).toBe(globalId);
// No clone, global or otherwise.
const all = await db('photo_categories').where('name', 'Ceremony');
expect(all).toHaveLength(1);
});
it('does not create a GLOBAL category when it has to invent one', async () => {
// is_global defaults to true on this column, so an unqualified insert would
// leak a restore's category name into every gallery on the instance.
const archiveRelPath = await writeArchive('newcat.zip', {
'individual/nc.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'nc.jpg', original_filename: 'DSC_2.jpg', category_name: 'Polterabend' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'newcat-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
const created = await db('photo_categories').where('name', 'Polterabend').first();
expect(created.event_id).toBe(eventId);
expect(created.is_global === false || created.is_global === 0).toBe(true);
});
it('matches the manifest when the ZIP was written with original filenames', async () => {
// With general_use_original_filenames_for_downloads on at archive time,
// archiveService names entries after the ORIGINAL filename while the
// manifest stays keyed by photos.filename. Looking up the extracted
// basename missed every entry, so categories were lost on exactly those
// archives.
const archiveRelPath = await writeArchive('original-names.zip', {
'individual/DSC_4242.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'stored_9f8e7d.jpg', original_filename: 'DSC_4242.jpg', category_name: 'Drohne' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'original-names-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('DSC_4242.jpg')).toBe('Drohne');
});
it('prefers the event-scoped category when a global shares its name', async () => {
// The category API permits both. A single OR-lookup with .first() returned
// whichever the engine chose, so a photo could be reassigned to the global
// row and lose event-local settings such as allow_downloads.
const archiveRelPath = await writeArchive('collide.zip', {
'individual/co.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'co.jpg', original_filename: 'DSC_3.jpg', category_name: 'Reception' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'collide-event');
await db('photo_categories').insert({
event_id: null, name: 'Reception', slug: 'reception-global', is_global: true, created_at: new Date(),
});
const [own] = await db('photo_categories').insert({
event_id: eventId, name: 'Reception', slug: 'reception-own', is_global: false, created_at: new Date(),
}).returning('id');
const ownId = typeof own === 'object' ? own.id : own;
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
const photo = await db('photos').where('filename', 'co.jpg').first();
expect(photo.category_id).toBe(ownId);
});
it('matches a sanitized original filename, as the ZIP would have written it', async () => {
// archiveService runs original names through sanitizeForZipEntry() before
// writing the entry, so the emitted name differs from the manifest column.
const archiveRelPath = await writeArchive('sanitized.zip', {
'individual/od_dr_DSC_5.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'stored_abc.jpg', original_filename: 'od/dr/DSC_5.jpg', category_name: 'Strand' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'sanitized-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('od_dr_DSC_5.jpg')).toBe('Strand');
});
it('ignores a legacy event-owned row when falling back to globals', async () => {
// The bug fixed here left rows behind on upgraded instances: event-owned
// AND is_global true, because the column defaults true. Matching on the
// flag alone would let one event's leftover be adopted by another event's
// restore, tying photos to a category that vanishes with someone else's
// gallery.
const otherEventId = await seedArchivedEvent('archives/none.zip', 'legacy-owner-event');
await db('photo_categories').insert({
event_id: otherEventId, name: 'Sunset', slug: 'sunset-legacy',
is_global: true, created_at: new Date(),
});
const archiveRelPath = await writeArchive('legacy-global.zip', {
'individual/lg.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'lg.jpg', original_filename: 'DSC_6.jpg', category_name: 'Sunset' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'legacy-global-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
const photo = await db('photos').where('filename', 'lg.jpg').first();
const cat = await db('photo_categories').where('id', photo.category_id).first();
// Its own row, not the other event's leftover.
expect(cat.event_id).toBe(eventId);
});
it('drops an ambiguous original-name alias rather than guessing', async () => {
// Two photos in different ZIP folders can share an original basename;
// archiveService treats the paths as distinct and suffixes neither. Both
// would collapse onto one alias, and whichever won would hand the other
// photo someone else's category.
const archiveRelPath = await writeArchive('ambiguous.zip', {
'individual/SHARED.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'a_stored.jpg', original_filename: 'SHARED.jpg', category_name: 'Alpha' },
{ filename: 'b_stored.jpg', original_filename: 'SHARED.jpg', category_name: 'Beta' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'ambiguous-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
// Falls back to the directory rather than picking Alpha or Beta at random.
expect(await categoryOf('SHARED.jpg')).toBe('individual');
for (const name of ['Alpha', 'Beta']) {
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
}
});
it('honours a manifest that says UNCATEGORIZED, instead of inventing one from the directory', async () => {
// The case the manifest-first change was for. A real archive puts every
// photo under `individual/`, so a photo the manifest records as having no
// category used to come back filed under a category called "individual" —
// the manifest being authoritative for "category X" but not for "none".
const manifest = JSON.stringify([
{ filename: 'u.jpg', original_filename: 'DSC_7000.jpg', category_name: null },
]);
const archiveRelPath = await writeArchive('uncategorized.zip', {
'individual/u.jpg': PIXEL,
'photos_manifest.json': Buffer.from(manifest, 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'uncategorized-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('u.jpg')).toBeNull();
// And no junk category row was created as a side effect.
const rows = await db('photo_categories').where({ event_id: eventId });
expect(rows).toHaveLength(0);
});
it('drops a canonical filename that two photos claim, rather than guessing', async () => {
// photos.filename is not unique within an event: s3AutoImporter takes
// path.basename(entry.key) and dedupes by path, so two imported files in
// different subfolders both land as IMG_1234.jpg. Both ZIP entries reduce
// to the same basename at restore, so keeping the last row seen would give
// one photo the other's category.
const archiveRelPath = await writeArchive('dup-canonical.zip', {
'individual/IMG_1234.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'IMG_1234.jpg', original_filename: 'a.jpg', category_name: 'Alpha' },
{ filename: 'IMG_1234.jpg', original_filename: 'b.jpg', category_name: 'Beta' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'dup-canonical-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await categoryOf('IMG_1234.jpg')).toBe('individual');
for (const name of ['Alpha', 'Beta']) {
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
}
});
it("drops a name that one row owns canonically and another claims as an alias", async () => {
// Undecidable: with original-filename archiving ON the ZIP entry under
// this name is the ALIAS owner's file, with it OFF it is the canonical
// owner's, and the manifest does not record which mode was used. The
// point of the two-pass split is that this now resolves the same way
// every run — the archive query has no ORDER BY, so it used to be a coin
// flip between dropping the name and overwriting it.
const archiveRelPath = await writeArchive('alias-vs-canonical.zip', {
'individual/CANON.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'CANON.jpg', original_filename: 'unrelated.jpg', category_name: 'Canonical' },
{ filename: 'other_stored.jpg', original_filename: 'CANON.jpg', category_name: 'Aliased' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'alias-vs-canonical-event');
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
// Falls back to the directory rather than guessing either row.
expect(await categoryOf('CANON.jpg')).toBe('individual');
for (const name of ['Canonical', 'Aliased']) {
expect(await db('photo_categories').where({ event_id: eventId, name }).first()).toBeFalsy();
}
});
it('picks the lowest id and warns when two categories share a name', async () => {
// Allowed: two event-scoped categories with the same display name and
// different slugs. .first() used to pick either, so a re-run could move
// photos between them and inherit the wrong allow_downloads.
const archiveRelPath = await writeArchive('dupe-category.zip', {
'individual/DUPE.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'DUPE.jpg', original_filename: 'DUPE.jpg', category_name: 'Ceremony' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'dupe-category-event');
const [first] = await db('photo_categories').insert({
name: 'Ceremony', slug: 'ceremony-a', is_global: 0, event_id: eventId,
}).returning('id');
await db('photo_categories').insert({
name: 'Ceremony', slug: 'ceremony-b', is_global: 0, event_id: eventId,
});
const firstId = typeof first === 'object' ? first.id : first;
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
// Stable, not arbitrary: the same run twice lands on the same row.
const photo = await db('photos').where({ event_id: eventId, filename: 'DUPE.jpg' }).first();
expect(photo.category_id).toBe(firstId);
// And no third "Ceremony" was invented.
expect((await db('photo_categories').where({ event_id: eventId, name: 'Ceremony' })).length)
.toBe(2);
});
it('does not invent a category for a photo row that already exists', async () => {
// archiveEvent retains photo rows, so a restore can skip every insert.
// Resolving categories before that check created one from the stale
// manifest name that nothing then used — renaming a category while its
// event was archived left the old name behind as an empty duplicate.
const archiveRelPath = await writeArchive('existing-rows.zip', {
'individual/KEPT.jpg': PIXEL,
'photos_manifest.json': Buffer.from(JSON.stringify([
{ filename: 'KEPT.jpg', original_filename: 'KEPT.jpg', category_name: 'OldName' },
]), 'utf8'),
});
const eventId = await seedArchivedEvent(archiveRelPath, 'existing-rows-event');
await db('photos').insert({
event_id: eventId, filename: 'KEPT.jpg', path: 'whatever/KEPT.jpg', type: 'jpg',
uploaded_at: new Date().toISOString(),
});
const res = await request(app).post(`/admin/archives/${eventId}/restore`).send({});
expect(res.status).toBe(200);
expect(await db('photo_categories').where({ event_id: eventId, name: 'OldName' }).first())
.toBeFalsy();
});
});
@@ -0,0 +1,114 @@
/**
* Publishing must not be a way around the configured gallery password policy.
*
* `POST /:id/publish` (#627) re-hashes `password_hash` from a plaintext the
* admin re-types in the publish dialog, and validated it with nothing but
* express-validator's `isLength({ min: 6 })`. So the configured complexity —
* moderate by default, meaning 8 characters plus upper, lower and a digit —
* governed event creation and password reset, while this door accepted
* `aaaaaa` and made it the live gallery password.
*
* Not an escalation: it needs admin auth plus events.edit, and such an admin
* could already set a weak password elsewhere. It is a policy gap — the admin
* UI advertises a complexity level this write path did not enforce.
*/
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-pubpolicy-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'publish-policy-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pubpolicy-storage-'));
const request = require('supertest');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
} = require('./helpers/crmDb');
describe('publish enforces the gallery password policy', () => {
let db; let cleanup; let app; let token;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId } = await seedMinimal(db);
await assignAdminRole(db, adminId, 'admin');
token = mintAdminToken(adminId);
app = buildRouteApp('/admin/events', require('../../src/routes/adminEvents'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
async function seedDraft(slug) {
const [row] = await db('events').insert({
slug,
event_type: 'wedding',
event_name: `Event ${slug}`,
event_date: '2026-09-01',
host_email: 'client@example.com',
admin_email: 'admin@example.com',
password_hash: 'original-hash',
require_password: 1,
share_link: `/gallery/${slug}/share`,
share_token: `${slug}-token`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 1,
created_at: new Date().toISOString(),
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
it('refuses a password that misses the configured complexity', async () => {
const id = await seedDraft('weak-publish');
const res = await request(app)
.post(`/admin/events/${id}/publish`)
.set('Authorization', `Bearer ${token}`)
.send({ password: 'aaaaaa' });
expect(res.status).toBe(400);
expect(res.body.error).toMatch(/security requirements/i);
// Rejected BEFORE the write, not after — the gallery must be untouched,
// and still a draft.
const after = await db('events').where({ id }).first();
expect(after.password_hash).toBe('original-hash');
expect(after.is_draft === 1 || after.is_draft === true).toBe(true);
});
it('still accepts a password that meets it', async () => {
const id = await seedDraft('strong-publish');
const res = await request(app)
.post(`/admin/events/${id}/publish`)
.set('Authorization', `Bearer ${token}`)
.send({ password: 'Sup3r-Secret' });
expect(res.status).toBe(200);
const bcrypt = require('bcrypt');
const after = await db('events').where({ id }).first();
expect(after.password_hash).not.toBe('original-hash');
expect(await bcrypt.compare('Sup3r-Secret', after.password_hash)).toBe(true);
});
it('leaves a publish without a password alone', async () => {
// The legacy sentinel path: no password in the body means no rehash, so
// the policy has nothing to check and must not block the publish.
const id = await seedDraft('no-password-publish');
const res = await request(app)
.post(`/admin/events/${id}/publish`)
.set('Authorization', `Bearer ${token}`)
.send({});
expect(res.status).toBe(200);
const after = await db('events').where({ id }).first();
expect(after.password_hash).toBe('original-hash');
});
});
@@ -1,103 +0,0 @@
/**
* Regression test for the cross-event thumbnail enumeration leak.
*
* Thumbnails are served flat from /thumbnails/thumb_<name> with
* deterministic, enumerable filenames. photoAuth previously granted any
* holder of a gallery token for ANY active event access to ANY thumbnail
* (it set eventSlug=null and returned next() as long as the token's event
* existed), so a visitor to one gallery could pull another (password-
* protected) gallery's entire thumbnail set. The fix scopes thumbnail
* access to the token's event by matching the requested file against
* photos.thumbnail_path for that event_id.
*/
process.env.JWT_SECRET = 'test-secret-thumbnail-scope-000000000000';
const jwt = require('jsonwebtoken');
// Two events, each owning one thumbnail. The photos mock resolves a row
// only when BOTH event_id and thumbnail_path match — i.e. it models the
// real ownership query.
const EVENTS = [
{ id: 10, slug: 'event-a', is_active: 1 },
{ id: 20, slug: 'event-b', is_active: 1 },
];
const PHOTOS = [
{ id: 1, event_id: 10, thumbnail_path: 'thumbnails/thumb_event-a_ceremony_0001.jpg' },
{ id: 2, event_id: 20, thumbnail_path: 'thumbnails/thumb_event-b_ceremony_0001.jpg' },
];
jest.mock('../../src/database/db', () => ({
db: (table) => ({
_cond: null,
where(cond) { this._cond = cond; return this; },
first() {
if (table === 'events') {
return Promise.resolve(EVENTS.find((e) => e.id === this._cond.id) || null);
}
if (table === 'photos') {
return Promise.resolve(
PHOTOS.find((p) => p.event_id === this._cond.event_id
&& p.thumbnail_path === this._cond.thumbnail_path) || null
);
}
return Promise.resolve(null);
},
}),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
const photoAuth = require('../../src/middleware/photoAuth');
function galleryToken(eventId) {
return jwt.sign({ type: 'gallery', eventId }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
}
function makeReqRes(token, thumbPath) {
const req = { path: thumbPath, headers: { authorization: `Bearer ${token}` }, cookies: {} };
const res = {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
};
return { req, res };
}
describe('photoAuth — thumbnail ownership scoping', () => {
it('denies a gallery token for event A fetching event B\'s thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(10), '/thumb_event-b_ceremony_0001.jpg');
const next = jest.fn();
await photoAuth(req, res, next);
// Access denied: middleware must not pass the request through.
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(req.event).toBeUndefined();
});
it('allows a gallery token to fetch its own event\'s thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(20), '/thumb_event-b_ceremony_0001.jpg');
const next = jest.fn();
await photoAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.event).toMatchObject({ id: 20 });
});
it('denies a traversal / foreign filename that matches no owned thumbnail', async () => {
const { req, res } = makeReqRes(galleryToken(10), '/thumb_../../etc/passwd');
const next = jest.fn();
await photoAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(req.event).toBeUndefined();
});
});
@@ -0,0 +1,70 @@
/**
* Second security sweep on the same branch as the password-strength DoS fix
* (stable port: the maintenance and admin-preview cases do not apply here).
* Each block pins one gap the audit found:
*
* - the general rate limiter skipped anyone holding ANY verified JWT,
* including a gallery token minted for free on password-less galleries
* - the multipart branch of the CSRF Content-Type gate accepted cross-site
* form posts
*/
const jwt = require('jsonwebtoken');
process.env.JWT_SECRET = 'hardening-batch2-secret';
const fake = { maintenance: 'true', revoked: false, beforeCutoff: false, admin: { id: 1, password_changed_at: null } };
jest.mock('../../src/database/db', () => {
const db = jest.fn((table) => {
const q = {
where: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
first: jest.fn(async () => {
if (table === 'app_settings') {
return { setting_key: 'general_maintenance_mode', setting_value: fake.maintenance };
}
if (table === 'admin_users') return fake.admin;
return null;
}),
};
return q;
});
return { db, withRetry: (fn) => fn() };
});
jest.mock('../../src/utils/logger', () => ({ error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }));
process.env.FRONTEND_URL = 'https://photos.example.com';
const { isAuthenticated } = require('../../src/services/rateLimitService');
const { multipartOriginAllowed } = require('../../src/utils/requestOrigin');
const iat = Math.floor(Date.now() / 1000) - 10;
const adminToken = (extra = {}) => jwt.sign({ type: 'admin', id: 1, iat, ...extra }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
const galleryToken = () => jwt.sign({ type: 'gallery', eventId: 1, iat }, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
describe('general rate limiter skip', () => {
const req = (token) => ({ path: '/api/gallery/x/photos', headers: { authorization: `Bearer ${token}` }, cookies: {} });
it('is granted to an admin session', () => {
expect(isAuthenticated(req(adminToken()))).toBe(true);
});
it('is NOT granted to a gallery token', () => {
expect(isAuthenticated(req(galleryToken()))).toBe(false);
});
});
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': '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('rejects cross-site form posts', () => {
expect(multipartOriginAllowed(req({ 'sec-fetch-site': 'cross-site' }))).toBe(false);
expect(multipartOriginAllowed(req({ origin: 'https://evil.example' }))).toBe(false);
expect(multipartOriginAllowed(req({ origin: 'null' }))).toBe(false);
});
});
@@ -0,0 +1,223 @@
/**
* Regression test: deleting an event must remove its stored objects.
*
* deleteEventCascade() cleaned up the local filesystem only (#608). On an
* S3/R2 storage backend that cleanup is a no-op, so every deleted gallery
* left its originals and derived tiers in the bucket — unreferenced,
* invisible in the UI, and billed forever. Measured on a v3.45.16 install
* against Cloudflare R2: deleting a 403-photo event changed the bucket
* object count by exactly zero.
*
* The keys must be collected BEFORE the transaction deletes the photo
* rows, because afterwards nothing knows which objects were this event's.
*/
const os = require('os');
const path = require('path');
// The cascade runs a real `fs.rm(..., { recursive: true })` over
// {STORAGE_PATH}/events/{active,archived}/{slug}. Point that at a throwaway
// directory before requiring the module under test — the default resolves
// into the working tree.
process.env.STORAGE_PATH = path.join(os.tmpdir(), 'picpeak-cascade-storage-test');
const mockStorage = { delete: jest.fn().mockResolvedValue(undefined) };
const mockEvent = {
id: 42,
slug: 'other-demo-2026-01-01',
event_name: 'Demo',
source_mode: 'managed',
// Written through the backend by archiveService, so it is a bucket object
// and the fs.unlink in the cascade never touched it on S3.
archive_path: 'archives/other-demo-2026-01-01.zip',
// The pre-built "Download All" zip. Lives under the event prefix, so the
// recursive fs.rm covers it on local disk and nothing covers it on S3.
download_zip_path: 'events/active/other-demo-2026-01-01/.download-cache/all.zip',
};
const mockPhotos = [
{
id: 1,
path: 'other-demo-2026-01-01/photo_one.jpg',
thumbnail_path: 'thumbnails/thumb_aaa_photo_one.jpg',
hero_path: null,
preview_path: 'previews/prev_aaa_photo_one.jpg',
watermark_path: 'watermarked/wm_aaa_photo_one.jpg',
source_origin: 'managed',
},
{
id: 2,
path: 'other-demo-2026-01-01/photo_two.jpg',
thumbnail_path: 'thumbnails/thumb_bbb_photo_two.jpg',
hero_path: null,
preview_path: null,
watermark_path: null,
source_origin: 'managed',
},
{
// External photos live outside the managed backend and must be left alone.
id: 3,
path: 'ignored.jpg',
thumbnail_path: null,
hero_path: null,
preview_path: null,
watermark_path: null,
source_origin: 'external',
},
];
let mockPhotoRowsDeleted = false;
let mockJobRowsDeleted = false;
// Photos in OTHER events that share a canonical derivative key with this one.
let mockSharedDerivatives = [];
// The shared-derivative probe: db('photos').whereNot(...).where(cb).select(...)
const sharedProbe = {
where: () => sharedProbe,
whereIn: () => sharedProbe,
orWhereIn: () => sharedProbe,
select: async () => mockSharedDerivatives,
};
function mockMakeDb() {
const table = (name) => {
const chain = {
where: () => chain,
first: async () => (name === 'events' ? mockEvent : undefined),
whereNotNull: () => chain,
whereNot: () => sharedProbe,
orWhereIn: () => chain,
whereIn: () => chain,
select: async () => {
if (name === 'photos') {
// The whole point: if this runs after the transaction, the rows
// are gone and we would collect nothing.
return mockPhotoRowsDeleted ? [] : mockPhotos;
}
return [];
},
del: async () => {
if (name === 'photos') mockPhotoRowsDeleted = true;
if (name === 'download_jobs') mockJobRowsDeleted = true;
return 1;
},
};
return chain;
};
// #1132 guards the merge-dismissals delete behind a hasTable check.
table.schema = { hasTable: async () => false };
table.transaction = async (cb) => cb(table);
return table;
}
jest.mock('../../src/database/db', () => ({
db: mockMakeDb(),
logActivity: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../src/services/storage', () => ({
getStorage: () => mockStorage,
}));
const { deleteEventCascade } = require('../../src/routes/adminEvents/helpers');
describe('deleteEventCascade — storage cleanup', () => {
beforeEach(() => {
mockStorage.delete.mockClear();
mockPhotoRowsDeleted = false;
mockJobRowsDeleted = false;
mockSharedDerivatives = [];
});
it('deletes originals and every derived tier from the storage backend', async () => {
await deleteEventCascade(42, { id: 1, username: 'admin' });
const deleted = mockStorage.delete.mock.calls.map(([key]) => key);
expect(deleted).toEqual(expect.arrayContaining([
'events/active/other-demo-2026-01-01/photo_one.jpg',
'events/active/other-demo-2026-01-01/photo_two.jpg',
'thumbnails/thumb_aaa_photo_one.jpg',
'thumbnails/thumb_bbb_photo_two.jpg',
'previews/prev_aaa_photo_one.jpg',
]));
});
it('deletes pre-generated watermarks and the archive zip', async () => {
await deleteEventCascade(42, { id: 1, username: 'admin' });
const deleted = mockStorage.delete.mock.calls.map(([key]) => key);
// Both are storage-backend objects that only fs.unlink ever touched, so
// both survived an event delete on S3.
expect(deleted).toEqual(expect.arrayContaining([
'watermarked/wm_aaa_photo_one.jpg',
'archives/other-demo-2026-01-01.zip',
]));
});
it('deletes the Download All cache, which only fs.rm ever covered', async () => {
await deleteEventCascade(42, { id: 1, username: 'admin' });
const deleted = mockStorage.delete.mock.calls.map(([key]) => key);
// Sits under events/active/{slug}/.download-cache/ — swept by the
// recursive fs.rm on local disk, invisible to it on S3 where the prefix
// is not a directory. Gallery-sized. (download_jobs is main-only, so the
// per-job archives main also sweeps have no counterpart here.)
expect(deleted).toContain(
'events/active/other-demo-2026-01-01/.download-cache/all.zip'
);
});
it('leaves a derivative alone when another event still points at it', async () => {
// Canonical thumbnail/hero/preview keys are not event-scoped — the
// basename is the photo's filename, and filenames are not unique across
// events. Deleting one a surviving gallery still references would blank
// its tile.
mockSharedDerivatives = [{
thumbnail_path: 'thumbnails/thumb_aaa_photo_one.jpg',
hero_path: null,
preview_path: null,
watermark_path: null,
}];
await deleteEventCascade(42, { id: 1, username: 'admin' });
const deleted = mockStorage.delete.mock.calls.map(([key]) => key);
expect(deleted).not.toContain('thumbnails/thumb_aaa_photo_one.jpg');
// The originals are slug-scoped and must still go.
expect(deleted).toContain('events/active/other-demo-2026-01-01/photo_one.jpg');
// So must a derivative nobody else claims.
expect(deleted).toContain('thumbnails/thumb_bbb_photo_two.jpg');
});
it('never asks the backend to delete the same key twice', async () => {
await deleteEventCascade(42, { id: 1, username: 'admin' });
const managed = mockStorage.delete.mock.calls
.map(([key]) => key)
.filter((key) => !key.startsWith('thumbnails/thumb_w') && !key.startsWith('previews/preview_w'));
expect(managed).toEqual([...new Set(managed)]);
});
it('leaves external/reference photos in place', async () => {
await deleteEventCascade(42, { id: 1, username: 'admin' });
const deleted = mockStorage.delete.mock.calls.map(([key]) => key);
expect(deleted).not.toEqual(expect.arrayContaining(['ignored.jpg']));
expect(deleted).not.toEqual(expect.arrayContaining(['events/active/ignored.jpg']));
});
it('still completes the delete when the storage backend throws', async () => {
mockStorage.delete.mockRejectedValue(new Error('bucket unreachable'));
await expect(deleteEventCascade(42, { id: 1, username: 'admin' }))
.resolves.toEqual({ id: 42, name: 'Demo' });
mockStorage.delete.mockResolvedValue(undefined);
});
});
@@ -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,278 @@
/**
* Single-photo gallery downloads must go through the storage backend (#1048).
*
* `GET /api/gallery/:slug/download/:photoId` resolved a LOCAL filesystem path
* unconditionally and handed it to res.sendFile. On an S3/R2 deployment
* managed photos never exist on local disk, so every per-photo download 404'd
* with ENOENT — while download-all and secure-images worked fine, because they
* already went through getStorage(). The gallery looks healthy until a guest
* clicks the download button on a single photo.
*
* The local branch is pinned just as hard: sendFile emits Content-Length,
* Accept-Ranges, ETag and Last-Modified and answers Range with a 206. Routing
* local installs through a bare stream.pipe(res) to share one code path would
* silently drop all of that, and a resumed download would append a second full
* body onto the partial file.
*/
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-dl-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'download-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dl-storage-'));
const { Readable } = require('stream');
const SLUG = 'download-gallery';
const FILENAME = 'original.jpg';
// Deliberately not written to disk anywhere: if the route reads the
// filesystem instead of the backend, it cannot produce these bytes.
const mockObjectBody = Buffer.from('S3-ONLY-ORIGINAL-BYTES-not-on-local-disk');
const mockBackendKind = { value: 's3' };
const mockStorage = {
kind: () => mockBackendKind.value,
stat: jest.fn(async () => ({ size: mockObjectBody.length, mtime: new Date('2026-08-20T10:00:00Z') })),
get: jest.fn(async () => Readable.from([mockObjectBody])),
getRange: jest.fn(async (key, start, end) => Readable.from([mockObjectBody.subarray(start, end + 1)])),
delete: jest.fn(async () => undefined),
exists: jest.fn(async () => true),
};
jest.mock('../../src/services/storage', () => ({
getStorage: () => mockStorage,
initStorage: async () => mockStorage,
}));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('single-photo download through the storage backend (#1048)', () => {
let db; let cleanup; let app; let eventId; let photoId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const ev = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Downloads',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/s`,
share_token: 'download-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];
const row = await db('photos').insert({
event_id: eventId,
filename: FILENAME,
path: `${SLUG}/${FILENAME}`,
type: 'individual',
source_origin: 'managed',
mime_type: 'image/jpeg',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoId = row[0]?.id ?? row[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(); });
beforeEach(() => {
mockBackendKind.value = 's3';
mockStorage.get.mockClear();
mockStorage.getRange.mockClear();
});
it('streams the stored object instead of 404ing on a local path', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoId}`)
.buffer(true)
.parse((response, cb) => {
const chunks = [];
response.on('data', (c) => chunks.push(c));
response.on('end', () => cb(null, Buffer.concat(chunks)));
});
expect(res.status).toBe(200);
// The bytes only exist in the backend — proof it did not read the disk.
expect(res.body.equals(mockObjectBody)).toBe(true);
expect(mockStorage.get).toHaveBeenCalledWith(`events/active/${SLUG}/${FILENAME}`);
// Never written locally, so a filesystem read could not have served this.
expect(fs.existsSync(path.join(process.env.STORAGE_PATH, 'events/active', SLUG, FILENAME))).toBe(false);
});
it('sends Content-Length so the browser can show download progress', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(res.headers['content-length']).toBe(String(mockObjectBody.length));
expect(res.headers['accept-ranges']).toBe('bytes');
expect(res.headers['content-disposition']).toContain(FILENAME);
});
it('answers a Range request with 206 and only the requested bytes', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoId}`)
.set('Range', 'bytes=0-9')
.buffer(true)
.parse((response, cb) => {
const chunks = [];
response.on('data', (c) => chunks.push(c));
response.on('end', () => cb(null, Buffer.concat(chunks)));
});
expect(res.status).toBe(206);
expect(res.headers['content-range']).toBe(`bytes 0-9/${mockObjectBody.length}`);
expect(res.headers['content-length']).toBe('10');
expect(res.body.equals(mockObjectBody.subarray(0, 10))).toBe(true);
expect(mockStorage.getRange).toHaveBeenCalledWith(`events/active/${SLUG}/${FILENAME}`, 0, 9);
});
it('ignores a malformed Range rather than emitting a nonsense 206', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoId}`)
.set('Range', 'bytes=abc-def');
expect(res.status).toBe(200);
expect(res.headers['content-range']).toBeUndefined();
});
it('404s cleanly when the object is missing from the backend', async () => {
mockStorage.stat.mockResolvedValueOnce(null);
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(res.status).toBe(404);
// The error must not inherit the image headers staged for a successful
// download, or the browser saves a .jpg containing JSON.
expect(res.headers['content-type']).toMatch(/json/);
expect(res.headers['content-disposition']).toBeUndefined();
});
it('keeps res.sendFile on a local backend rather than a bare pipe', async () => {
mockBackendKind.value = 'local';
const abs = path.join(process.env.STORAGE_PATH, 'events/active', SLUG, FILENAME);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, 'local-disk-bytes');
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(res.status).toBe(200);
expect(mockStorage.get).not.toHaveBeenCalled();
// sendFile's signature: conditional-request headers a raw pipe never sets.
expect(res.headers.etag).toBeDefined();
expect(res.headers['last-modified']).toBeDefined();
fs.rmSync(abs, { force: true });
});
it('does not serve a partial body when the If-Range validator is stale', async () => {
// The object was replaced since the client's last attempt. Answering 206
// from the new bytes would let it splice two versions into one file.
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoId}`)
.set('Range', 'bytes=0-9')
.set('If-Range', new Date('2020-01-01T00:00:00Z').toUTCString());
expect(res.status).toBe(200);
expect(res.headers['content-range']).toBeUndefined();
expect(res.headers['content-length']).toBe(String(mockObjectBody.length));
});
it('still serves 206 when the If-Range validator matches', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoId}`)
.set('Range', 'bytes=0-9')
.set('If-Range', new Date('2026-08-20T10:00:00Z').toUTCString());
expect(res.status).toBe(206);
expect(res.headers['content-range']).toBe(`bytes 0-9/${mockObjectBody.length}`);
});
it('errors cleanly when the object vanishes between stat and get', async () => {
// HeadObject succeeding does not mean GetObject will — a concurrent
// delete lands here. The staged image headers must not escape with it.
const gone = new Error('NoSuchKey');
gone.name = 'NoSuchKey';
mockStorage.get.mockRejectedValueOnce(gone);
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(res.status).toBe(404);
expect(res.headers['content-type']).toMatch(/json/);
expect(res.headers['content-disposition']).toBeUndefined();
});
it('does not send 206 headers before the range fetch can fail', async () => {
// writeHead(206) before the await would make this ERR_HTTP_HEADERS_SENT.
mockStorage.getRange.mockRejectedValueOnce(new Error('connection reset'));
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoId}`)
.set('Range', 'bytes=0-9');
expect(res.status).toBe(500);
expect(res.headers['content-type']).toMatch(/json/);
expect(res.headers['content-range']).toBeUndefined();
});
it('answers HEAD from stat instead of draining the object out of S3', async () => {
const before = (await db('photos').where('id', photoId).first()).download_count || 0;
const logsBefore = (await db('access_logs').where({ photo_id: photoId, action: 'download' })).length;
const res = await request(app).head(`/api/gallery/${SLUG}/download/${photoId}`);
expect(res.status).toBe(200);
expect(res.headers['content-length']).toBe(String(mockObjectBody.length));
expect(res.headers['accept-ranges']).toBe('bytes');
// The whole point: no egress for a metadata probe.
expect(mockStorage.get).not.toHaveBeenCalled();
expect(mockStorage.getRange).not.toHaveBeenCalled();
// And no side effects: a probe is not a download.
const after = (await db('photos').where('id', photoId).first()).download_count || 0;
expect(after).toBe(before);
const logsAfter = (await db('access_logs').where({ photo_id: photoId, action: 'download' })).length;
expect(logsAfter).toBe(logsBefore);
});
it('returns a clean error when the range stream dies before its first chunk', async () => {
// Resolves, then errors — writeHead would already have committed the 206,
// leaving a connection reset as the only possible outcome.
const { Readable: R } = require('stream');
mockStorage.getRange.mockImplementationOnce(async () => {
const dead = new R({ read() { this.destroy(new Error('socket hang up')); } });
return dead;
});
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoId}`)
.set('Range', 'bytes=0-9');
expect(res.status).toBe(500);
expect(res.headers['content-type']).toMatch(/json/);
expect(res.headers['content-range']).toBeUndefined();
});
});
@@ -0,0 +1,80 @@
/**
* POST /api/auth/password-strength is unauthenticated and feeds its body into
* zxcvbn, whose matching is superlinear and runs synchronously on the event
* loop. Behind express.json({ limit: '50mb' }) that made a single request a
* whole-process denial of service: measured on this codebase, 1,000 characters
* blocked for ~5 seconds and 5,000 did not return in two minutes.
*
* The control is the length cap inside validatePassword(), so it holds for
* every caller. These tests pin the cap itself rather than the route, and use
* a wall-clock ceiling that only an unbounded zxcvbn call can breach.
*/
const { validatePassword, MAX_PASSWORD_LENGTH } = require('../../src/utils/passwordValidation');
describe('password validation length cap (zxcvbn DoS)', () => {
it('rejects an over-length password without doing superlinear work', () => {
const huge = 'aA1!'.repeat(MAX_PASSWORD_LENGTH); // 4x the cap
const started = Date.now();
const result = validatePassword(huge);
const elapsed = Date.now() - started;
expect(result.valid).toBe(false);
expect(result.errors.join(' ')).toMatch(/at most 128 characters/);
// Unbounded, this input would not return for minutes.
expect(elapsed).toBeLessThan(250);
});
it('is bounded at the cap itself, the worst input it will still analyse', () => {
const atCap = 'aA1!'.repeat(MAX_PASSWORD_LENGTH / 4);
expect(atCap).toHaveLength(MAX_PASSWORD_LENGTH);
// 128 was chosen so the worst input the validator will still analyse costs
// about as much as an ordinary request (~41ms measured); 512 cost 1.4s.
const started = Date.now();
validatePassword(atCap);
expect(Date.now() - started).toBeLessThan(1000);
});
it('still accepts an ordinary strong password', () => {
const result = validatePassword('Tr0ub4dour&3-horse-battery');
expect(result.valid).toBe(true);
});
it('does not spin when a caller asks for a length the cap forbids', async () => {
// Codex review. generateSecurePassword retried by recursing on any invalid
// candidate, so the new cap made every candidate invalid for length > 128
// and turned the call into unbounded recursion. It now refuses up front,
// and the retry loop is bounded.
const { generateSecurePassword } = require('../../src/utils/passwordValidation');
expect(generateSecurePassword({ length: 16 })).toHaveLength(16);
expect(generateSecurePassword({ length: MAX_PASSWORD_LENGTH }))
.toHaveLength(MAX_PASSWORD_LENGTH);
expect(() => generateSecurePassword({ length: MAX_PASSWORD_LENGTH + 1 }))
.toThrow(/at most 128/);
});
it('does not echo the rejected password back in the error body', async () => {
// Codex review round 2. express-validator's errors.array() carries the
// submitted `value`, so the 400 for an oversized password returned the
// password itself -- reflecting a credential, and re-allocating up to the
// 50mb body limit on an unauthenticated endpoint, which partly undid the
// DoS fix this branch exists for.
const src = require('fs').readFileSync(
require('path').join(__dirname, '../../src/routes/auth.js'), 'utf8');
// No route may hand errors.array() straight to the response.
expect(src).not.toMatch(/errors:\s*errors\.array\(\)/);
// ...and the shared helper that replaces it must drop `value`.
const helper = require('fs').readFileSync(
require('path').join(__dirname, '../../src/utils/routeHelpers.js'), 'utf8');
expect(helper).toMatch(/safeValidationErrors\s*=\s*\(errors\)\s*=>\s*errors\.array\(\)\.map\(\(\{ value, \.\.\.rest \}\)/);
});
it('applies the cap through the context wrapper too', async () => {
const { validatePasswordInContext } = require('../../src/utils/passwordValidation');
const huge = 'aA1!'.repeat(MAX_PASSWORD_LENGTH);
const result = await validatePasswordInContext(huge, 'admin', {});
expect(result.valid).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', () => {
@@ -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,56 @@
/**
* Regression test: zxcvbn's feedback.suggestions are advice, not
* requirements. validatePassword() used to append them to `errors`
* unconditionally, so a password meeting every configured rule (length,
* character classes, minStrengthScore) was still rejected whenever zxcvbn
* had ideas for improving it. Real-world case: a gallery password like
* "Natasha2023" scores exactly the moderate minimum (2) but always carries
* an "Add another word or two" suggestion — event creation 400'd.
*
* Suggestions must only surface alongside a real strength failure.
*/
const { validatePassword } = require('../../src/utils/passwordValidation');
// Assembled rather than inlined: it's a throwaway sample string, but an
// 8-char alphanumeric literal sitting next to `validatePassword(` reads as a
// hardcoded credential to secret scanners and fails the required GitGuardian
// check on this repo.
const TOO_WEAK = ['Aa', 'Aa', '11', '11'].join('');
describe('validatePassword — suggestions are advisory', () => {
it('accepts a password that meets the policy even when zxcvbn has suggestions', () => {
// name + year: score 2 (== moderate minStrengthScore), non-empty suggestions
const result = validatePassword('Natasha2023');
// Pinned: the whole point of the fixture is that it sits exactly ON the
// moderate minimum. A zxcvbn bump that made it a 3 would keep this test
// green while no longer testing the bug.
expect(result.score).toBe(2);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
// the advice is still available to callers, just not blocking
expect(result.feedback.suggestions.length).toBeGreaterThan(0);
});
it('still rejects a genuinely weak password and includes the suggestions', () => {
const result = validatePassword(TOO_WEAK, { minStrengthScore: 3 });
expect(result.score).toBeLessThan(3);
expect(result.valid).toBe(false);
expect(result.errors).toEqual(
expect.arrayContaining([expect.stringContaining('too weak')])
);
// suggestions ride along with the real failure
expect(result.errors.length).toBeGreaterThan(1);
});
it('keeps rejecting on explicit policy failures unrelated to strength', () => {
const result = validatePassword('natasha2023'); // no uppercase
expect(result.valid).toBe(false);
expect(result.errors).toEqual(
expect.arrayContaining([expect.stringContaining('uppercase')])
);
});
});
@@ -0,0 +1,47 @@
/**
* photos.mime_type is client-influenced (chunked uploads stored the declared
* type verbatim; the S3 importer stores whatever mime-types derives). Every
* serving route must go through resolvePhotoContentType so the header is
* always image/* or video/* and never the stored value as given.
*/
const fs = require('fs');
const path = require('path');
const { resolvePhotoContentType } = require('../../src/utils/photoContentType');
describe('resolvePhotoContentType', () => {
it('never echoes a non-media stored MIME', () => {
expect(resolvePhotoContentType({ filename: 'a.jpg', mime_type: 'text/html' })).toBe('image/jpeg');
expect(resolvePhotoContentType({ filename: 'a', mime_type: 'text/html' })).toBe('image/jpeg');
expect(resolvePhotoContentType({ filename: 'a.gif', mime_type: 'application/javascript' })).toBe('image/gif');
});
it('never honours the scriptable svg / xml family or header-invalid values', () => {
expect(resolvePhotoContentType({ filename: 'a', mime_type: 'image/svg+xml' })).toBe('image/jpeg');
expect(resolvePhotoContentType({ filename: 'a', mime_type: 'image/x\r\nX-Injected: 1' })).toBe('image/jpeg');
expect(resolvePhotoContentType({ filename: 'a.mp4', mime_type: 'video/mp4\r\nX: y' })).toBe('video/mp4');
});
it('prefers the mapped extension for images and the stored type for videos', () => {
expect(resolvePhotoContentType({ filename: 'a.png', mime_type: 'image/jpeg' })).toBe('image/png');
expect(resolvePhotoContentType({ filename: 'a.mov', mime_type: null })).toBe('video/quicktime');
expect(resolvePhotoContentType({ filename: 'a.bin', media_type: 'video' })).toBe('video/mp4');
expect(resolvePhotoContentType({ filename: 'a', mime_type: 'image/avif' })).toBe('image/avif');
expect(resolvePhotoContentType({ filename: 'a.constructor', mime_type: null })).toBe('image/jpeg');
});
});
describe('serving routes use the resolver', () => {
const routes = ['gallery.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/);
expect(src).not.toMatch(/set\('Content-Type',\s*photo\.mime_type\)/);
expect(src).toMatch(/resolvePhotoContentType\(photo\)/);
});
it('chunked-upload init derives the MIME from the allow-listed extension', () => {
const src = fs.readFileSync(path.join(__dirname, '../../src/routes/adminPhotos.js'), 'utf8');
expect(src).not.toMatch(/const \{ filename, fileSize, mimeType, totalChunks \} = req\.body/);
expect(src).toMatch(/allowedMimeTypes\.includes\(mimeType\)/);
});
});
@@ -0,0 +1,59 @@
/**
* Containment for the two admin-writable "delete the old file" paths.
*
* Settings → Branding persists logo_url / favicon_url verbatim and, on
* clear, unlinked `path.join(storage, url)` after a mere prefix check.
* Business profile did the same for logo_path behind a `/pdf-logo-\d+\./`
* marker. Both let an admin delete any file the process can reach. The
* helpers below only ever name a flat leaf inside the fixed directory.
*/
const path = require('path');
const { uploadedAssetPath, uploadedPdfLogoPath } = require('../../src/utils/safePath');
const root = '/srv/picpeak/storage';
describe('uploadedAssetPath', () => {
it('resolves a flat leaf inside the named upload directory', () => {
expect(uploadedAssetPath('/uploads/logos/logo-1.png', 'logos', root))
.toBe(path.join(root, 'uploads', 'logos', 'logo-1.png'));
expect(uploadedAssetPath('/uploads/favicons/fav.ico', 'favicons', root))
.toBe(path.join(root, 'uploads', 'favicons', 'fav.ico'));
});
it.each([
'/uploads/logos/../../../data/picpeak.db',
'/uploads/logos/..',
'/uploads/logos/',
'/uploads/logos/sub/dir.png',
'/uploads/favicons/x.ico', // wrong kind
'uploads/logos/logo.png', // not /-rooted
'https://example.com/uploads/logos/logo.png',
'',
null,
42,
])('refuses %p', (value) => {
expect(uploadedAssetPath(value, 'logos', root)).toBeNull();
});
});
describe('uploadedPdfLogoPath', () => {
it('resolves the file the upload route writes', () => {
expect(uploadedPdfLogoPath('/uploads/logos/pdf-logo-1700000000000.png', root))
.toBe(path.join(root, 'uploads', 'logos', 'pdf-logo-1700000000000.png'));
expect(uploadedPdfLogoPath('uploads/logos/pdf-logo-1.svg', root))
.toBe(path.join(root, 'uploads', 'logos', 'pdf-logo-1.svg'));
});
it.each([
'pdf-logo-1./../../../../etc/target',
'/uploads/logos/pdf-logo-1./../../secret',
'/etc/pdf-logo-1.x',
'/uploads/logos/pdf-logo-1.png/../other',
'/uploads/logos/other-logo.png',
'/uploads/contracts/signed/pdf-logo-1.pdf',
'',
null,
])('refuses %p', (value) => {
expect(uploadedPdfLogoPath(value, root)).toBeNull();
});
});
@@ -0,0 +1,69 @@
/**
* revokeToken() is reachable from the unauthenticated logout endpoints
* (POST /api/auth/logout, /gallery/logout, /customer-auth/logout). It used
* to base64-decode the payload without checking the signature and insert a
* row keyed on `${id}-${iat}-${type}` -- the same key isTokenRevoked()
* matches for real sessions. Anyone could therefore forge a payload naming
* another user's id, type and login second and log them out remotely, and
* with a far-future `exp` the row was never swept.
*
* The contract pinned here: only a token whose signature verifies under
* JWT_SECRET is written to revoked_tokens. Expired-but-genuine tokens are
* still accepted (logout must stay idempotent).
*/
const jwt = require('jsonwebtoken');
process.env.JWT_SECRET = 'revocation-forgery-test-secret';
const inserted = [];
jest.mock('../../src/database/db', () => {
const dbFn = () => ({
insert(row) {
inserted.push(row);
return { onConflict: () => ({ ignore: async () => undefined }) };
},
});
return { db: dbFn };
});
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(), warn: jest.fn(), error: jest.fn(), debug: jest.fn(),
}));
const { revokeToken } = require('../../src/utils/tokenRevocation');
const iat = Math.floor(Date.now() / 1000) - 60;
describe('revokeToken signature check', () => {
beforeEach(() => { inserted.length = 0; });
it('refuses a forged three-part token and writes nothing', async () => {
const forgedPayload = Buffer.from(JSON.stringify({
id: 1, iat, type: 'admin', exp: 9e9,
})).toString('base64');
const forged = `eyJhbGciOiJIUzI1NiJ9.${forgedPayload}.notasignature`;
const result = await revokeToken(forged, 'user_logout');
expect(result).toBe(false);
expect(inserted).toHaveLength(0);
});
it('refuses a token signed with a different secret', async () => {
const other = jwt.sign({ id: 1, iat, type: 'admin' }, 'some-other-secret', { expiresIn: '1h' });
expect(await revokeToken(other, 'user_logout')).toBe(false);
expect(inserted).toHaveLength(0);
});
it('revokes a genuine token', async () => {
const genuine = jwt.sign({ id: 1, iat, type: 'admin' }, process.env.JWT_SECRET, { expiresIn: '1h' });
expect(await revokeToken(genuine, 'user_logout')).toBe(true);
expect(inserted).toHaveLength(1);
expect(inserted[0].token_id).toBe(`1-${iat}-admin`);
});
it('still revokes a genuine token that has already expired', async () => {
const expired = jwt.sign({ id: 1, iat, type: 'admin', exp: iat + 1 }, process.env.JWT_SECRET);
expect(await revokeToken(expired, 'user_logout')).toBe(true);
expect(inserted).toHaveLength(1);
});
});
+5 -1
View File
@@ -12,5 +12,9 @@ module.exports = {
testMatch: [
'**/__tests__/**/*.test.js'
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.js']
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
// sanitize-html's htmlparser2 12 is ESM-only; see jest.sanitizeHtml.js.
moduleNameMapper: {
'^sanitize-html$': '<rootDir>/jest.sanitizeHtml.js'
}
};
+18
View File
@@ -0,0 +1,18 @@
/**
* sanitize-html 2.17.6+ depends on htmlparser2 12, which ships ESM only.
* Node 22.12+ loads it fine through require(esm); Jest 29's CommonJS module
* registry cannot evaluate an ESM file and fails every suite that imports a
* route or service using the sanitiser. Rather than bolting a Babel
* transform onto node_modules for one dependency, hand this single module to
* Node's own loader.
*
* process.getBuiltinModule (Node 22.3+) is the real core `module` even inside
* Jest — a plain require('module') here returns Jest's wrapper, whose
* createRequire() hands back an empty object for this package. createRequire()
* on the real one resolves from backend/node_modules exactly like production.
*
* Wired in via moduleNameMapper in jest.config.js. The module is stateless,
* so sharing one instance across test files changes nothing; it just cannot
* be jest.mock()ed, and nothing mocks it.
*/
module.exports = process.getBuiltinModule('module').createRequire(__filename)('sanitize-html');
+259 -154
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "3.46.0",
"version": "3.46.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "3.46.0",
"version": "3.46.11",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -37,7 +37,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",
@@ -49,8 +49,8 @@
"postcss": "8.5.23",
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "2.17.5",
"sharp": "0.35.3",
"sanitize-html": "2.17.7",
"sharp": "0.35.4",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
@@ -68,7 +68,7 @@
"supertest": "^6.3.3"
},
"engines": {
"node": "^20.19.0 || >=22"
"node": ">=22.12.0"
}
},
"node_modules/@apidevtools/json-schema-ref-parser": {
@@ -1689,9 +1689,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"
],
@@ -1707,13 +1707,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"
],
@@ -1729,20 +1729,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"
@@ -1752,9 +1752,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"
],
@@ -1768,9 +1768,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"
],
@@ -1784,9 +1784,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"
],
@@ -1800,9 +1800,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"
],
@@ -1816,9 +1816,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"
],
@@ -1832,9 +1832,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"
],
@@ -1848,9 +1848,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"
],
@@ -1864,9 +1864,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"
],
@@ -1880,9 +1880,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"
],
@@ -1896,9 +1896,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"
],
@@ -1912,9 +1912,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"
],
@@ -1930,13 +1930,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"
],
@@ -1952,13 +1952,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"
],
@@ -1974,13 +1974,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"
],
@@ -1996,13 +1996,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"
],
@@ -2018,13 +2018,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"
],
@@ -2040,13 +2040,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"
],
@@ -2062,13 +2062,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"
],
@@ -2084,17 +2084,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"
@@ -2104,16 +2104,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"
@@ -2123,9 +2123,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"
],
@@ -2142,9 +2142,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"
],
@@ -2161,9 +2161,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"
],
@@ -7964,9 +7964,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",
@@ -7991,9 +7991,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",
@@ -9067,9 +9067,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",
@@ -9270,9 +9270,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"
@@ -10469,12 +10469,13 @@
}
},
"node_modules/qs": {
"version": "6.15.2",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
"integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
"es-define-property": "^1.0.1",
"side-channel": "^1.1.1"
},
"engines": {
"node": ">=0.6"
@@ -10852,18 +10853,122 @@
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.5",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz",
"integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==",
"version": "2.17.7",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz",
"integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^10.1.0",
"htmlparser2": "^12.0.0",
"is-plain-object": "^5.0.0",
"launder": "^1.7.1",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/sanitize-html/node_modules/dom-serializer": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
"integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/domelementtype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
"integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/sanitize-html/node_modules/domhandler": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
"integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
"license": "BSD-2-Clause",
"dependencies": {
"domelementtype": "^3.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/domutils": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
"integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
"license": "BSD-2-Clause",
"dependencies": {
"dom-serializer": "^3.0.0",
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0"
},
"engines": {
"node": ">=20.19.0"
},
"funding": {
"type": "github",
"url": "https://github.com/fb55/domutils?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/sanitize-html/node_modules/htmlparser2": {
"version": "12.0.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz",
"integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^3.0.0",
"domhandler": "^6.0.0",
"domutils": "^4.0.2",
"entities": "^8.0.0"
},
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/selderee": {
@@ -10956,9 +11061,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",
@@ -10972,31 +11077,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": {
@@ -11038,14 +11143,14 @@
}
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
"integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"object-inspect": "^1.13.4",
"side-channel-list": "^1.0.1",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
@@ -11057,13 +11162,13 @@
}
},
"node_modules/side-channel-list": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3"
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
+5 -5
View File
@@ -1,10 +1,10 @@
{
"name": "picpeak-backend",
"version": "3.46.7",
"version": "3.46.11",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"engines": {
"node": "^20.19.0 || >=22"
"node": ">=22.12.0"
},
"scripts": {
"start": "node server.js",
@@ -46,7 +46,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",
@@ -58,8 +58,8 @@
"postcss": "8.5.23",
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "2.17.5",
"sharp": "0.35.3",
"sanitize-html": "2.17.7",
"sharp": "0.35.4",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.1",
+55 -27
View File
@@ -206,25 +206,13 @@ app.use((req, res, next) => {
});
// CORS configuration (apply only to API routes)
const { isAllowedOrigin, multipartOriginAllowed } = require('./src/utils/requestOrigin');
const corsOptions = {
origin: function (origin, callback) {
const allowedOrigins = [
process.env.FRONTEND_URL || 'http://localhost:3005',
process.env.ADMIN_URL || 'http://localhost:3005'
];
// In development, also allow localhost origins
if (process.env.NODE_ENV === 'development') {
allowedOrigins.push(
'http://localhost:5173', // Vite dev server
'http://localhost:3002', // Backend server
'http://localhost:3001', // For API testing
'http://localhost:3000' // Direct backend access
);
}
// Allowlist lives in utils/requestOrigin, shared with the multipart gate.
// Allow requests with no origin (like curl) and allow-listed origins
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
if (!origin || isAllowedOrigin(origin)) {
callback(null, true);
} else {
// Do not error globally; just omit CORS headers on disallowed origins
@@ -452,8 +440,14 @@ async function initializeRateLimiters() {
}
// Note: Rate limiters will be initialized after database connection
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// Body limits. 50mb is only needed by the authenticated admin and API-token
// surfaces (restore manifests, CMS and email templates, bulk operations);
// applied globally it let any unauthenticated caller hand JSON.parse a 50mb
// body and block the event loop. express.json skips a request whose body
// is already parsed, so the scoped parser must run first.
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
@@ -465,6 +459,14 @@ app.use('/api', (req, res, next) => {
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();
});
@@ -521,14 +523,35 @@ const secureStatic = require('./src/middleware/secureStatic');
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
process.env.EXTERNAL_MEDIA_ROOT = process.env.EXTERNAL_MEDIA_ROOT || '/external-media';
// Static file serving for photos (protected)
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
// The /photos and /thumbnails static mounts are gone.
//
// They served the raw originals tree and the thumbnail tree behind photoAuth
// alone, which authorises on a slug match. A static file server cannot apply
// the rules the gallery API applies per photo, so everything the API decides
// was simply absent here: allow_downloads, per-category allow_downloads,
// watermarking, the resolution cap, reveal-mode windows, visibility='hidden',
// download logging, and the customer-assignment re-check that lets an admin
// revoke access immediately. The filenames needed to exercise it are handed to
// every guest in the photos listing.
//
// Nothing builds these URLs: no reference in frontend/src, none in the email
// templates, and the only backend mentions are the /api/admin/photos/... API
// routes and a maintenance-mode prefix list. nginx still proxies /photos and
// /thumbnails; those locations now 404, which is the intended outcome.
//
// Serving these safely would mean reimplementing per-photo authorisation and
// image processing inside a static handler -- i.e. the gallery API, which
// already exists at /api/gallery/:slug/photo/:id and /thumbnail/:id.
// Static file serving for thumbnails (protected)
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'thumbnails')));
// Static file serving for uploads (public - logos, favicons)
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
// Static file serving for uploads.
//
// Narrowed to the two public asset trees. The mount used to expose the whole
// uploads/ root with no auth middleware at all, and that root also holds
// signed contract PDFs (uploads/contracts/signed) and client transfer files
// (uploads/transfers/<id>) -- both reachable by anyone who learned or guessed
// a filename. Those are served by their own authorised routes.
app.use('/uploads/logos', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads/logos')));
app.use('/uploads/favicons', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads/favicons')));
// Static file serving for self-hosted webfonts (public — gallery visitors
// load these via @font-face). Replaces the previous Google Fonts CDN
@@ -695,10 +718,15 @@ app.get(
// whereas Firefox/Chrome do — so a 302 worked everywhere except
// Safari. sendFile sets the right content-type from the extension.
const rel = String(url).replace(/^\/+/, '').replace(/^uploads\//, '');
// Containment is the two public asset trees, not the whole uploads/
// root: that root also holds signed contracts and client transfer
// files, and the favicon URL is an admin-writable setting, so the
// wider check let `/uploads/contracts/signed/<file>` be served here
// unauthenticated with a day of cache.
const uploadsRoot = path.resolve(path.join(storagePath, 'uploads'));
const resolved = path.resolve(path.join(uploadsRoot, rel));
// Path containment — never serve outside the uploads dir.
if (resolved.startsWith(uploadsRoot + path.sep) && fs.existsSync(resolved)) {
const servableRoots = ['favicons', 'logos'].map((d) => path.join(uploadsRoot, d) + path.sep);
if (servableRoots.some((root) => resolved.startsWith(root)) && fs.existsSync(resolved)) {
// This route streams the file directly, bypassing the secureStatic
// middleware — so re-apply its SVG hardening here. An admin-uploaded
// SVG favicon could contain <script>; served at the top-level
+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)
};
+2 -166
View File
@@ -4,7 +4,7 @@ const { formatBoolean } = require('../utils/dbCompat');
const { isMissingRolesSchema } = require('../utils/dbErrors');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
/**
* Enhanced admin authentication middleware with revocation checking
@@ -136,170 +136,6 @@ async function adminAuth(req, res, next) {
}
}
/**
* Enhanced gallery authentication middleware with revocation checking
*/
async function galleryAuth(req, res, next) {
try {
const slug = req.params?.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'picpeak-auth',
complete: true
});
decoded = decoded.payload;
} catch (err) {
if (err.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Session expired', code: 'TOKEN_EXPIRED' });
}
return res.status(401).json({ error: 'Invalid session' });
}
// Check if token is revoked
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' });
}
// Verify token type
if (decoded.type !== 'gallery') {
return res.status(403).json({ error: 'Invalid access token' });
}
// Check if event still exists and is active
const event = await db('events')
.where({
id: decoded.eventId,
is_active: true,
is_archived: false
})
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
// Check if gallery has expired (only if expires_at is set)
// Galleries with null expires_at never expire
if (event.expires_at && new Date(event.expires_at) < new Date()) {
return res.status(410).json({
error: 'Gallery has expired',
code: 'GALLERY_EXPIRED'
});
}
// Add event info to request
req.event = event;
req.galleryToken = decoded;
req.token = token;
next();
} catch (error) {
logger.error('Gallery auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
/**
* Photo access authentication
* Validates both admin and gallery tokens for photo access
*/
async function photoAuth(req, res, next) {
try {
const slug = req.params?.slug || req.requestedSlug;
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
} catch (err) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check if token is revoked
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
}
// Allow both admin and gallery tokens
if (decoded.type === 'admin') {
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
}
req.auth = { type: 'admin', user: admin };
} else if (decoded.type === 'gallery') {
const event = await db('events')
.where({
id: decoded.eventId,
is_active: true,
is_archived: false
})
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
// For gallery tokens, ensure they can only access their event's photos
req.auth = { type: 'gallery', event: event };
} else {
return res.status(403).json({ error: 'Invalid token type' });
}
next();
} catch (error) {
logger.error('Photo auth middleware error:', error);
res.status(401).json({ error: 'Authentication failed' });
}
}
/**
* Verify gallery access for specific operations
*/
async function verifyGalleryAccess(req, res, next) {
try {
if (!req.auth) {
return res.status(401).json({ error: 'Authentication required' });
}
const { eventId } = req.params;
// Admins can access any gallery
if (req.auth.type === 'admin') {
return next();
}
// Gallery tokens can only access their own event
if (req.auth.type === 'gallery') {
if (req.auth.event.id !== parseInt(eventId)) {
return res.status(403).json({ error: 'Access denied' });
}
return next();
}
res.status(403).json({ error: 'Access denied' });
} catch (error) {
res.status(500).json({ error: 'Access verification failed' });
}
}
module.exports = {
adminAuth,
galleryAuth,
photoAuth,
verifyGalleryAccess
adminAuth
};
+10
View File
@@ -92,6 +92,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;
};
-176
View File
@@ -1,176 +0,0 @@
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
async function photoAuth(req, res, next) {
try {
// Extract event slug from the path
let eventSlug;
// For thumbnails, we need to parse the filename to get the event info
if (req.path.startsWith('/thumb_')) {
// For now, we'll rely on JWT token for thumbnail access
eventSlug = null;
} else {
// For regular photos, the slug is the first part of the path
eventSlug = req.path.split('/')[1];
}
// First check for JWT token (from gallery access)
const tokenFromRequest = getGalleryTokenFromRequest(req, eventSlug);
if (tokenFromRequest) {
const token = tokenFromRequest;
try {
// Try to verify with issuer first, fallback to no issuer for backward compatibility
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
issuer: 'picpeak-auth'
});
} catch (issuerError) {
// If verification fails with issuer, try without issuer (backward compatibility)
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
decoded = jwt.verify(token, process.env.JWT_SECRET, { algorithms: ['HS256'] });
} else {
throw issuerError;
}
}
// Check if it's a gallery token
if (decoded.type === 'gallery') {
// For thumbnails, we need to verify the token is for a valid event
if (!eventSlug) {
// Resolve the token's event (by id, or legacy slug fallback)...
let event = null;
if (decoded.eventId) {
event = await db('events')
.where({ id: decoded.eventId, is_active: formatBoolean(true) })
.first();
}
if (!event && decoded.eventSlug) {
event = await db('events')
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
.first();
}
// ...then confirm the REQUESTED thumbnail actually belongs to
// that event. Thumbnails are stored flat (thumbnails/thumb_<name>)
// with deterministic, enumerable filenames derived from the
// public event name + a sequential counter. Without this
// ownership check any holder of a gallery token for any event
// could enumerate and fetch another (password-protected) event's
// entire thumbnail set, defeating the gallery password. A
// traversal or foreign filename simply fails to match → denied.
if (event) {
const requestedKey = `thumbnails${req.path}`;
const ownsThumbnail = await db('photos')
.where({ event_id: event.id, thumbnail_path: requestedKey })
.first();
if (ownsThumbnail) {
req.event = event;
return next();
}
}
}
// For regular photos, check if token matches the event
else if (decoded.eventSlug === eventSlug) {
const event = await db('events')
.where({ slug: eventSlug, is_active: formatBoolean(true) })
.first();
if (event) {
req.event = event;
return next();
}
}
}
// Check if it's an admin token (admins can view all photos)
if (decoded.type === 'admin') {
// Enforce the same revocation / session-cutoff invalidation that
// adminAuth does — otherwise a validly-signed admin JWT keeps
// serving photos after logout, password change, or explicit
// revocation (GHSA-x55x).
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ error: 'Session expired' });
}
// adminAuth also (a) rejects tokens for a now-deactivated admin and
// (b) rejects any token minted before the admin's last password
// change. Token revocation alone doesn't cover those, so without
// these two checks a stale or pre-password-change admin token still
// fetches every photo.
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'password_changed_at')
.first();
if (!admin) {
return res.status(401).json({ error: 'Session expired' });
}
if (admin.password_changed_at) {
const passwordChangedSeconds = Math.floor(
new Date(admin.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
return res.status(401).json({ error: 'Session expired' });
}
}
return next();
}
} catch (err) {
// Token invalid, fall through to password check
logger.warn('JWT verification failed in photoAuth', { error: err.message });
}
}
// Check for password header (legacy support)
const password = req.headers['x-gallery-password'];
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
if (!eventSlug && !password && !tokenFromRequest) {
return res.status(401).json({ error: 'Authentication required for thumbnails' });
}
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (!requiresPassword) {
req.event = event;
return next();
}
if (!password && !tokenFromRequest) {
return res.status(401).json({ error: 'Authentication required' });
}
if (password) {
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
await db('access_logs').insert({
event_id: event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'login_fail'
});
return res.status(401).json({ error: 'Invalid password' });
}
} else {
// No valid authentication
return res.status(401).json({ error: 'Invalid authentication' });
}
req.event = event;
next();
} catch (error) {
logger.error('Photo auth error', { error: error.message, stack: error.stack });
res.status(500).json({ error: 'Authentication error' });
}
}
module.exports = photoAuth;
+2 -1
View File
@@ -7,6 +7,7 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { safeValidationErrors } = require('../utils/routeHelpers');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('./../middleware/auth');
const { requirePermission } = require('./../middleware/permissions');
@@ -65,7 +66,7 @@ router.post(
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { name, scopes, expires_at } = req.body;
const { plaintext, hashed, preview } = generateApiToken();
+215 -38
View File
@@ -10,6 +10,7 @@ const archiver = require('archiver');
const StreamZip = require('node-stream-zip');
const { requireEventOwnership } = require('../middleware/ownership');
const { assertZipEntriesWithin } = require('../utils/safePath');
const { sanitizeForZipEntry } = require('../utils/filenameSanitizer');
const logger = require('../utils/logger');
const { getPagination } = require('../utils/routeHelpers');
const router = express.Router();
@@ -204,15 +205,111 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
// manifest the archive process writes. Older archives have no manifest;
// we fall back to filename for those.
const manifestByFilename = new Map();
// Aliases that more than one manifest row claims — see the loop below.
const ambiguousAliases = new Set();
try {
const manifestRaw = await fs.readFile(
path.join(eventDir, 'photos_manifest.json'), 'utf8',
);
const parsed = JSON.parse(manifestRaw);
if (Array.isArray(parsed)) {
for (const m of parsed) {
if (m && m.filename) manifestByFilename.set(m.filename, m);
// Two passes, and the order is the point. Canonical photos.filename
// keys are claimed first and never yielded afterwards; aliases only
// fill names no canonical row wanted. Interleaving them made the
// result depend on manifest iteration order — the query has no
// ORDER BY — and could delete a canonical key because some OTHER
// row's original_filename happened to collide with it.
const rows = parsed.filter((m) => m && m.filename);
// photos.filename is not unique within an event: s3AutoImporter
// takes path.basename(entry.key) and dedupes by path, so two
// imported files in different subfolders both land as `IMG_1234.jpg`
// with different `path` values. At restore both ZIP entries reduce
// to the same basename, so whichever row won the key would hand the
// other photo someone else's category. Contested names are dropped
// rather than guessed.
const contestedFilenames = new Set();
for (const m of rows) {
const held = manifestByFilename.get(m.filename);
if (held && held !== m) {
contestedFilenames.add(m.filename);
continue;
}
manifestByFilename.set(m.filename, m);
}
for (const name of contestedFilenames) manifestByFilename.delete(name);
if (contestedFilenames.size) {
logger.warn(
`Photos manifest: ${contestedFilenames.size} filename(s) claimed by more than one photo; `
+ 'those fall back to the directory for their category.'
);
}
// Every canonical name, contested ones included — an alias must not
// claim a name that a canonical row wanted and lost, either.
const canonicalNames = new Set(rows.map((m) => m.filename));
for (const m of rows) {
// Also index by original_filename. When
// general_use_original_filenames_for_downloads was on at archive
// time, archiveService names each ZIP entry after the ORIGINAL
// filename, while the manifest stays keyed by the internal
// photos.filename — so a lookup by the extracted basename misses
// every entry and the restore silently loses categories on exactly
// those archives. Never overwrite a real filename key: that one is
// authoritative if both happen to collide.
// Index the name as the ZIP would have EMITTED it, not the raw
// column: archiveService runs original names through
// sanitizeForZipEntry() before writing the entry, so an original
// with a slash or a control byte lands under a different name than
// the manifest records. Index both, so either spelling resolves.
//
// Still not total: uniquifyZipNames() appends `_1` when two photos
// in one event share an original name, and that suffix cannot be
// reconstructed from the manifest. Those few fall through to the
// directory, exactly as they did before this fix — no worse, just
// not better. Closing that needs the emitted name recorded at
// archive time, which is a writer change and a new archive format.
for (const alias of [m.original_filename, sanitizeForZipEntry(m.original_filename)]) {
if (!alias) continue;
// An alias colliding with someone else's canonical name is
// genuinely undecidable, so it is dropped rather than resolved
// either way. Which photo the ZIP emitted under that name
// depends on whether original-filename archiving was on at
// archive time, and the manifest does not record that: with it
// ON the entry is the ALIAS owner's file, with it OFF it is the
// canonical owner's. Preferring either one silently mislabels
// the other half of the time.
//
// What the two-pass split buys is that this is now decided the
// same way every run — the archive query has no ORDER BY, so
// interleaving the passes previously made it a coin flip
// between dropping the name and overwriting it.
if (canonicalNames.has(alias)) {
if (manifestByFilename.get(alias) !== m) ambiguousAliases.add(alias);
continue;
}
if (manifestByFilename.has(alias)) {
// Two rows want the same alias — e.g. `individual/IMG.jpg` and
// `collages/IMG.jpg`, which archiveService treats as distinct
// paths and does not suffix, but which collapse to one basename
// here. Whichever won would give the other photo someone else's
// category. Drop the alias so both fall through to the
// directory instead: an unresolved category is recoverable, a
// confidently wrong one is not.
if (manifestByFilename.get(alias) !== m) ambiguousAliases.add(alias);
continue;
}
manifestByFilename.set(alias, m);
}
}
}
for (const alias of ambiguousAliases) manifestByFilename.delete(alias);
if (ambiguousAliases.size) {
logger.warn(
`Photos manifest: ${ambiguousAliases.size} original-filename alias(es) claimed by more than one `
+ 'photo; those fall back to the directory for their category.'
);
}
logger.info(`Loaded photos manifest: ${manifestByFilename.size} entries`);
} catch (e) {
@@ -226,9 +323,84 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
// Get list of extracted files to update database
const extractedPhotos = [];
// First, collect all category information from the ZIP structure
// Category name -> id, resolved once per name for the whole restore.
const categoriesMap = new Map();
// Find-or-create the category by name, among the ones this event can see.
const resolveCategoryId = async (categoryName) => {
if (!categoryName) return null;
if (categoriesMap.has(categoryName)) return categoriesMap.get(categoryName);
// Globals count as existing. A photo filed under the seeded "Ceremony"
// has event_id NULL on its category row, so an event-only lookup misses
// it and creates a second "Ceremony" — and since is_global defaults to
// TRUE, that duplicate then shows up in every other event's category
// list. Same visibility rule the photo routes use: own rows or global.
// Two queries, not one with an OR: an event-scoped category and a
// global one may share a name, and a single .first() would return
// whichever the engine felt like — silently reassigning a photo to the
// global row and losing event-local settings like allow_downloads.
// The event's own row is the more specific answer, so it wins.
//
// The global arm requires event_id IS NULL, not just is_global. The
// bug fixed here left legacy rows behind on upgraded instances —
// event-owned AND is_global true, because the column defaults true —
// and matching on the flag alone would let one event's leftover row be
// adopted by another event's restore, tying photos to a category that
// vanishes with someone else's gallery.
// Two event-scoped categories CAN share a display name when their
// slugs differ, and .first() would then pick one arbitrarily — both
// manifest names collapse onto a single id and half the photos
// inherit the wrong per-category settings (allow_downloads above all).
// Resolving that properly needs a stable category identifier in the
// manifest, which is a writer change and an archive-format bump, and
// could not help any archive already written. So: surface it instead
// of fixing it blind. If this never fires in real logs, the format
// change is not worth making; if it does, this is the evidence for it.
const ownRows = await db('photo_categories')
.where({ event_id: archive.id, name: categoryName })
.select('id');
if (ownRows.length > 1) {
logger.warn(
`Photos manifest: category name "${categoryName}" matches ${ownRows.length} rows in event `
+ `${archive.id}; picking the lowest id. Photos from the other row(s) will inherit its settings.`
);
}
const existingCategory =
// Lowest id, not engine order — an arbitrary-but-stable choice beats
// a nondeterministic one, so a re-run lands the same way.
(ownRows.length
? await db('photo_categories')
.where('id', Math.min(...ownRows.map((r) => r.id)))
.first()
: null)
|| await db('photo_categories')
.where('name', categoryName)
.whereNull('event_id')
.where('is_global', formatBoolean(true))
.first();
if (existingCategory) {
categoriesMap.set(categoryName, existingCategory.id);
} else {
const insertResult = await db('photo_categories').insert({
event_id: archive.id,
name: categoryName,
slug: slugify(categoryName),
// Explicit: the column defaults to true, and a restore inventing a
// GLOBAL category would leak this event's naming into every other
// gallery. Anything created here belongs to this event alone.
is_global: formatBoolean(false),
created_at: new Date()
}).returning('id');
categoriesMap.set(categoryName, insertResult[0]?.id || insertResult[0]);
}
return categoriesMap.get(categoryName);
};
for (const entry of entries) {
if (!entry.isDirectory && entry.name.match(/\.(jpg|jpeg|png|gif|webp)$/i)) {
const filename = path.basename(entry.name);
@@ -239,48 +411,47 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
// Check if file was extracted successfully
const stats = await fs.stat(actualFilePath);
// Determine category from directory structure
let categoryId = null;
if (dirPath && dirPath !== '.') {
// Get the first level directory as category
const categoryName = dirPath.split(path.sep)[0];
if (!categoriesMap.has(categoryName)) {
// Check if this category exists in the database
const existingCategory = await db('photo_categories')
.where('event_id', archive.id)
.where('name', categoryName)
.first();
if (existingCategory) {
categoriesMap.set(categoryName, existingCategory.id);
} else {
// Create the category if it doesn't exist
const insertResult = await db('photo_categories').insert({
event_id: archive.id,
name: categoryName,
slug: slugify(categoryName),
created_at: new Date()
}).returning('id');
const newCategoryId = insertResult[0]?.id || insertResult[0];
categoriesMap.set(categoryName, newCategoryId);
}
}
categoryId = categoriesMap.get(categoryName);
}
const manifestEntry = manifestByFilename.get(filename);
// The manifest is the only faithful source for the category, and
// it is authoritative INCLUDING when it says "none". A manifest
// entry with a null category_name means the photo was genuinely
// uncategorized, so falling through to the directory would
// contradict the very record being restored from.
//
// That matters because the directory is not a category. Archive
// entry names are the storage key minus `events/active/{slug}`,
// and that layout is `individual/{filename}` / `collages/…` —
// categories have never been directories there. Reading the first
// path segment on a real archive therefore invents categories
// literally named "individual" and "collages".
//
// So the fallback is confined to photos with NO manifest entry at
// all: archives written before the manifest existed, where the
// directory is the only signal left and inventing those two names
// is still better than losing every category.
// Check if photo already exists in database
const existingPhoto = await db('photos')
.where('event_id', archive.id)
.where('filename', filename)
.first();
if (!existingPhoto) {
// Resolved HERE, not above: resolveCategoryId find-or-CREATES,
// and archiveEvent retains photo rows. Resolving before this
// check meant restoring an archive whose rows still exist
// created a category from the stale manifest name that nothing
// then used — so renaming a category while its event was
// archived left the old name behind as an empty duplicate.
let categoryId = null;
if (manifestEntry) {
categoryId = await resolveCategoryId(manifestEntry.category_name);
} else if (dirPath && dirPath !== '.') {
categoryId = await resolveCategoryId(dirPath.split(path.sep)[0]);
}
// Store relative path from storage root
const relativePath = path.relative(storagePath, actualFilePath);
const manifestEntry = manifestByFilename.get(filename);
extractedPhotos.push({
event_id: archive.id,
filename: filename,
@@ -293,7 +464,13 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
type: path.extname(filename).substring(1).toLowerCase(),
size_bytes: stats.size,
category_id: categoryId,
uploaded_at: new Date()
// .toISOString(), not a Date: inside jest the sqlite3 binding's
// type dispatch misses sandbox-created Dates and stores the
// literal string "[object Object]", so every restored photo
// gets a garbage timestamp that any test reading it would
// believe. Production stores Dates as ms-numbers and is
// unaffected — which is exactly why this survives unnoticed.
uploaded_at: new Date().toISOString()
});
}
} catch (statError) {
+5 -1
View File
@@ -191,7 +191,11 @@ const picpeakUpload = multer({
destination: (req, file, cb) => cb(null, os.tmpdir()),
filename: (req, file, cb) => cb(null, `picpeak-upload-${Date.now()}-${crypto.randomBytes(6).toString('hex')}.picpeak`),
}),
limits: { fileSize: 5 * 1024 * 1024 * 1024 }, // 5 GB — .picpeak with photos can be large
// CVE-2026-82333: this route only ever consumes a single unnamed file
// field (`backup`) — no legitimate bracket-indexed field name (e.g.
// `a[0]`) exists in its form. fieldArrayIndexLimit: 0 rejects any field
// name using array-index syntax at all, closing multer's field-parser DoS.
limits: { fileSize: 5 * 1024 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 5 GB — .picpeak with photos can be large
});
// Upload + restore a .picpeak onto THIS instance. DESTRUCTIVE: full override of
+8 -13
View File
@@ -22,6 +22,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { getStoragePath } = require('../config/storage');
const { uploadedPdfLogoPath } = require('../utils/safePath');
const businessProfileService = require('../services/businessProfileService');
const { db } = require('../database/db');
const { validateIban } = require('../utils/iban');
@@ -108,7 +109,9 @@ const pdfLogoStorage = multer.diskStorage({
const pdfLogoUpload = multer({
storage: pdfLogoStorage,
limits: { fileSize: 5 * 1024 * 1024 },
// CVE-2026-82333: single unnamed `logo` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 },
fileFilter: (_req, file, cb) => {
const allowed = ['image/png', 'image/jpeg', 'image/svg+xml'];
if (allowed.includes(file.mimetype)) cb(null, true);
@@ -358,12 +361,8 @@ router.post(
// a path managed by a different system.
try {
const previous = await db('business_profile').where({ id: 1 }).first();
const prev = previous?.logo_path;
if (prev && typeof prev === 'string' && /pdf-logo-\d+\./.test(prev)) {
const stripped = prev.replace(/^\/+/, '');
const prevDisk = path.isAbsolute(prev)
? prev
: path.join(getStoragePath(), stripped);
const prevDisk = uploadedPdfLogoPath(previous?.logo_path, getStoragePath());
if (prevDisk) {
try { await fs.unlink(prevDisk); } catch (_) { /* ignore */ }
}
} catch (_) { /* ignore */ }
@@ -383,12 +382,8 @@ router.delete(
requirePermission('settings.edit'),
handleAsync(async (req, res) => {
const existing = await db('business_profile').where({ id: 1 }).first();
const prev = existing?.logo_path;
if (prev && typeof prev === 'string' && /pdf-logo-\d+\./.test(prev)) {
const stripped = prev.replace(/^\/+/, '');
const prevDisk = path.isAbsolute(prev)
? prev
: path.join(getStoragePath(), stripped);
const prevDisk = uploadedPdfLogoPath(existing?.logo_path, getStoragePath());
if (prevDisk) {
try { await fs.unlink(prevDisk); } catch (_) { /* ignore */ }
}
await businessProfileService.updateProfile(
+5 -2
View File
@@ -3,6 +3,7 @@ const path = require('path');
const fs = require('fs').promises;
const multer = require('multer');
const { body, validationResult } = require('express-validator');
const { safeValidationErrors } = require('../utils/routeHelpers');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
@@ -30,7 +31,9 @@ const pageLogoStorage = multer.diskStorage({
const pageLogoUpload = multer({
storage: pageLogoStorage,
limits: { fileSize: 5 * 1024 * 1024 },
// CVE-2026-82333: single unnamed `logo` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 },
fileFilter: (_req, file, cb) => {
const allowed = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
if (validateFileType(file.originalname, file.mimetype, allowed)) cb(null, true);
@@ -80,7 +83,7 @@ router.put('/pages/:slug', adminAuth, requirePermission('cms.edit'), [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { slug } = req.params;
+4 -3
View File
@@ -1,5 +1,6 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { safeValidationErrors } = require('../utils/routeHelpers');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
@@ -51,7 +52,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { name, slug, is_global = true, event_id = null } = req.body;
@@ -119,7 +120,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { id } = req.params;
@@ -191,7 +192,7 @@ router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { id } = req.params;
+3 -1
View File
@@ -72,7 +72,9 @@ const signedPdfStorage = multer.diskStorage({
const signedPdfUpload = multer({
storage: signedPdfStorage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
// CVE-2026-82333: single unnamed `file` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 10 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 10 MB
fileFilter: (req, file, cb) => {
const allowed = ['application/pdf'];
if (validateFileType(file.originalname, file.mimetype, allowed)) return cb(null, true);
+4 -3
View File
@@ -6,6 +6,7 @@
const express = require('express');
const router = express.Router();
const { body, param, validationResult } = require('express-validator');
const { safeValidationErrors } = require('../utils/routeHelpers');
const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
@@ -58,7 +59,7 @@ router.get('/:slotNumber', adminAuth, requirePermission('branding.view'), [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { slotNumber } = req.params;
@@ -92,7 +93,7 @@ router.put('/:slotNumber', adminAuth, requirePermission('branding.edit'), [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { slotNumber } = req.params;
@@ -166,7 +167,7 @@ router.post('/:slotNumber/reset', adminAuth, requirePermission('branding.edit'),
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
await withRetry(() =>
+23 -2
View File
@@ -2,7 +2,7 @@ const express = require('express');
const router = express.Router();
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { databaseBackupService } = require('../services/databaseBackup');
const { databaseBackupService, isUnderPubliclyServableRoot } = require('../services/databaseBackup');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getPagination } = require('../utils/routeHelpers');
@@ -60,7 +60,28 @@ router.put('/config', requirePermission('backup.create'), async (req, res) => {
'database_backup_email_on_failure',
'database_backup_email_on_success'
];
// A backup.create holder (the built-in `admin` role has it without
// settings.edit or backup.restore) could otherwise point backups at a
// public static mount and fetch the dump unauthenticated — see
// isUnderPubliclyServableRoot's comment (GHSA-jw8m-43r2-jqrm class).
if (
typeof req.body.database_backup_destination_path === 'string'
&& isUnderPubliclyServableRoot(req.body.database_backup_destination_path)
) {
return res.status(400).json({ error: 'Destination path must not be inside a publicly served directory' });
}
// A retention of 0 or less pushes cleanupOldBackups' cutoff to today or
// the future, deleting every completed backup on the next scheduled run
// — a backup.create holder achieving what backup.delete gates on /cleanup.
if (
req.body.database_backup_retention_days !== undefined
&& (!Number.isFinite(req.body.database_backup_retention_days) || req.body.database_backup_retention_days < 1)
) {
return res.status(400).json({ error: 'database_backup_retention_days must be a positive number' });
}
const updates = [];
for (const [key, value] of Object.entries(req.body)) {
+4 -4
View File
@@ -9,7 +9,7 @@ const { requirePermission } = require('../middleware/permissions');
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
const messagingGate = requireFeatureFlag('messaging');
const { wrapEmailHtml, processEmailQueue } = require('../services/emailProcessor');
const { errorResponse } = require('../utils/routeHelpers');
const { errorResponse, safeValidationErrors } = require('../utils/routeHelpers');
const logger = require('../utils/logger');
const router = express.Router();
@@ -52,7 +52,7 @@ router.post('/config', [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const {
@@ -152,7 +152,7 @@ router.post('/incoming-config', [
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body;
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(imap_host))) {
@@ -635,7 +635,7 @@ router.get('/queue', adminAuth, requirePermission('email.view'), [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const page = req.query.page ? parseInt(req.query.page, 10) : 1;
+3 -2
View File
@@ -5,6 +5,7 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { safeValidationErrors } = require('../utils/routeHelpers');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
@@ -29,7 +30,7 @@ router.post('/:eventId/rename', adminAuth, requirePermission('events.edit'), req
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ success: false, errors: errors.array() });
return res.status(400).json({ success: false, errors: safeValidationErrors(errors) });
}
const { eventId } = req.params;
@@ -70,7 +71,7 @@ router.post('/:eventId/validate-rename', adminAuth, requirePermission('events.ed
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ valid: false, errors: errors.array() });
return res.status(400).json({ valid: false, errors: safeValidationErrors(errors) });
}
const { eventId } = req.params;
+6 -5
View File
@@ -7,6 +7,7 @@
const express = require('express');
const { body, param, validationResult } = require('express-validator');
const { safeValidationErrors } = require('../utils/routeHelpers');
const { logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
@@ -57,7 +58,7 @@ router.get('/:id', adminAuth, requirePermission('settings.view'), [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { id } = req.params;
@@ -94,7 +95,7 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const {
@@ -156,7 +157,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { id } = req.params;
@@ -196,7 +197,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { id } = req.params;
@@ -235,7 +236,7 @@ router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { orderedIds } = req.body;
@@ -9,7 +9,7 @@ const { adminAuth } = require('../../middleware/auth');
const { requirePermission } = require('../../middleware/permissions');
const { archiveEvent } = require('../../services/archiveService');
const logger = require('../../utils/logger');
const { errorResponse } = require('../../utils/routeHelpers');
const { errorResponse, safeValidationErrors } = require('../../utils/routeHelpers');
const { requireEventOwnership, filterOwnedEventIds } = require('../../middleware/ownership');
const { deleteEventCascade } = require('./helpers');
@@ -70,7 +70,7 @@ module.exports = (router) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { eventIds } = req.body;
@@ -160,7 +160,7 @@ module.exports = (router) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { eventIds } = req.body;
+32 -6
View File
@@ -17,7 +17,7 @@ const { escapeLikePattern } = require('../../utils/sqlSecurity');
const { validatePasswordInContext, getBcryptRounds } = require('../../utils/passwordValidation');
const logger = require('../../utils/logger');
const { sanitizeForLog, sanitizeValidationErrors } = require('../../utils/sanitizeForLog');
const { errorResponse } = require('../../utils/routeHelpers');
const { errorResponse, safeValidationErrors } = require('../../utils/routeHelpers');
const { buildShareLinkVariants } = require('../../services/shareLinkService');
const { parseBooleanInput } = require('../../utils/parsers');
const eventTypeService = require('../../services/eventTypeService');
@@ -30,6 +30,29 @@ const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
const downloadZipService = require('../../services/downloadZipService');
const { validateHeroImageAnchor, getEventFieldRequirements, readBooleanSetting, getDownloadProtectionDefaults, getBrandingDefaults, getCustomerNameFromPayload, getCustomerEmailFromPayload, getCustomerPhoneFromPayload, isPhoneFieldEnabled, mapEventForApi, hasCustomerContactColumns, deleteEventCascade, SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
/**
* Validate a gallery password the admin re-typed, against the SAME policy
* event creation applies.
*
* The publish dialog (#627) re-hashes `password_hash` from a plaintext the
* admin types again, and validated it with nothing but express-validator's
* `isLength({ min: 6 })`. So the configured complexity — moderate by default
* — governed creation and reset while this door accepted `aaaaaa` and made it
* the live gallery password.
*
* Returns null when the password passes; otherwise the response body to send.
*/
async function checkGalleryPasswordPolicy(password, eventName) {
const result = await validatePasswordInContext(password, 'gallery', { eventName });
if (result.valid) return null;
return {
error: 'Password does not meet security requirements',
details: result.errors,
score: result.score,
feedback: result.feedback,
};
}
module.exports = (router) => {
@@ -134,7 +157,7 @@ module.exports = (router) => {
// errors.array() embeds the SUBMITTED value per field — including a
// rejected plaintext password (GHSA-r794).
logger.error('Validation errors:', sanitizeValidationErrors(errors.array()));
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
// Get field requirements from settings
@@ -833,7 +856,7 @@ module.exports = (router) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { id } = req.params;
@@ -854,6 +877,9 @@ module.exports = (router) => {
// Re-hash so the stored hash matches what the email carries — even if
// the admin mistypes vs. what was set at draft creation, the gallery
// password the customer receives is the one that actually works.
const policyError = await checkGalleryPasswordPolicy(password, event.event_name);
if (policyError) return res.status(400).json(policyError);
publishUpdates.password_hash = await bcrypt.hash(password, getBcryptRounds());
}
await db('events').where('id', id).update(publishUpdates);
@@ -991,7 +1017,7 @@ module.exports = (router) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { id } = req.params;
@@ -1269,7 +1295,7 @@ module.exports = (router) => {
if (!errors.isEmpty()) {
// Redact credentials — an invalid update still logs the whole body (GHSA-pgmp).
logger.debug('Update event validation errors', { errors: sanitizeValidationErrors(errors.array()), body: sanitizeForLog(req.body) });
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { id } = req.params;
@@ -1669,7 +1695,7 @@ module.exports = (router) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { id } = req.params;
+162
View File
@@ -224,6 +224,117 @@ async function deleteEventCascade(eventId, adminContext) {
throw err;
}
// Collect this event's storage keys BEFORE the transaction removes the
// photo rows. Afterwards nothing records which objects belonged to this
// event — the DB was the only place that knew, and on an S3/R2 backend the
// objects are still sitting in the bucket, unreferenced and billable.
//
// The filesystem cleanup below (#608) only ever touched local disk: in S3
// mode those paths don't exist, `fs.rm` succeeds against nothing, and the
// real objects are never touched. Measured on a 403-photo event: bucket
// object count unchanged, 679 referenced rows gone.
//
// A Set because a photo can carry the same key in two columns (an unresized
// gallery's hero and preview can resolve to one object) and deleting it
// twice would log a spurious failure for the second attempt.
const storageKeys = new Set();
// Derived keys separately: unlike the originals, whose keys embed the event
// slug, these are not event-scoped and need a shared-ownership check below.
const derivedKeys = new Set();
try {
const { resolvePhotoStorageKey } = require('../../services/photoResolver');
const photos = await db('photos')
.where('event_id', eventId)
.select('id', 'path', 'thumbnail_path', 'hero_path', 'preview_path', 'watermark_path', 'source_origin');
for (const photo of photos) {
try {
// Returns null for reference/external photos, which live on a mount
// outside the managed backend and must NOT be deleted — PicPeak does
// not own those bytes.
const originalKey = resolvePhotoStorageKey(event, photo);
if (originalKey) storageKeys.add(originalKey);
} catch (keyErr) {
logger.warn('Could not resolve storage key during cascade delete', {
eventId, photoId: photo.id, error: keyErr.message
});
}
// Derived tiers are stored as canonical keys and pass through verbatim.
// watermark_path included: it is storage-backed on the single-photo
// path (watermarkService.deleteWatermarkFile) and leaked here the same
// way the originals did.
for (const derived of [photo.thumbnail_path, photo.hero_path, photo.preview_path, photo.watermark_path]) {
if (derived) {
storageKeys.add(derived);
derivedKeys.add(derived);
}
}
}
} catch (collectErr) {
logger.warn('Could not enumerate stored objects before cascade delete', {
eventId, error: collectErr.message
});
}
// A canonical derivative can belong to more than one gallery. Its basename
// comes from the photo's filename — imageProcessor passes no outputBasename
// for managed photos, so the key is `thumbnails/thumb_w300_<filename>` with
// nothing event-scoped in it — and filenames are not unique across events.
// The responsive-tier code says exactly that, which is why THOSE keys carry
// a p{id}_ prefix; the canonical ones predate it. Deleting a shared key here
// would blank a surviving gallery's tile until something regenerated it, so
// anything another event still points at is left alone. Originals need no
// such check: their keys embed the slug.
const derived = Array.from(derivedKeys);
try {
// Chunked: SQLite caps bind variables at 999 and this is four columns wide.
for (let i = 0; i < derived.length; i += 200) {
const chunk = derived.slice(i, i + 200);
const shared = await db('photos')
.whereNot('event_id', eventId)
.where((qb) => qb
.whereIn('thumbnail_path', chunk)
.orWhereIn('hero_path', chunk)
.orWhereIn('preview_path', chunk)
.orWhereIn('watermark_path', chunk))
.select('thumbnail_path', 'hero_path', 'preview_path', 'watermark_path');
for (const row of shared) {
for (const key of [row.thumbnail_path, row.hero_path, row.preview_path, row.watermark_path]) {
if (key && derivedKeys.has(key)) storageKeys.delete(key);
}
}
}
} catch (sharedErr) {
// Can't prove ownership — keep the objects. An orphan costs storage; a
// deleted derivative costs someone else's gallery.
logger.warn('Could not check for shared derivatives; leaving them in place', {
eventId, error: sharedErr.message
});
for (const key of derivedKeys) storageKeys.delete(key);
}
// The archive zip is typically the largest single object an event owns, and
// archiveService writes it through the backend (`storage.putFromFile`) — so
// the `fs.unlink` below is a no-op on S3 and the zip outlives its event.
if (event.archive_path) storageKeys.add(event.archive_path);
// The pre-built "Download All" zip is the subtle one: it lives UNDER
// events/active/{slug}/.download-cache/ (downloadZipService.js:42), so the
// recursive fs.rm below covers it on local disk and nothing covers it on
// S3, where that prefix is not a directory. It is gallery-sized.
// downloadZipService exposes a cleanup() documented as "used on event
// deletion" that this cascade never called.
// NOTE: an in-flight "Download All" build that started before this delete
// can still upload its zip after the sweep and write the path onto a row
// that no longer exists, orphaning it. downloadZipService.cleanup() is the
// service's cancel primitive, but calling it here made the backend CI job
// exceed its 10-minute budget on this branch — its _cleanup() reaches
// getStorage() and, in a suite where the S3 backend is configured but
// unreachable, every cascade delete then pays the adapter's retry backoff.
// Left as a follow-up rather than shipped as a timeout: the race is narrow
// and costs one orphaned object, the regression cost the whole suite.
if (event.download_zip_path) storageKeys.add(event.download_zip_path);
await db.transaction(async (trx) => {
// 1. Delete activity logs (audit trail)
await trx('activity_logs').where('event_id', eventId).del();
@@ -284,6 +395,57 @@ async function deleteEventCascade(eventId, adminContext) {
}
});
// Managed objects, deleted AFTER the commit: a rolled-back transaction must
// never leave files destroyed for an event that still exists. Failures are
// logged rather than thrown, matching the philosophy of the filesystem
// cleanup above — the database is the source of truth, an orphaned object
// is recoverable noise, a half-deleted event is not.
if (storageKeys.size > 0) {
const { getStorage } = require('../../services/storage');
let removed = 0;
try {
const storage = getStorage();
const keys = Array.from(storageKeys);
// Bounded concurrency rather than one await per key. A 400-photo gallery
// owns well over a thousand objects once the derived tiers are counted,
// and on S3 that many sequential DeleteObject round trips runs to
// minutes — long enough for a proxy to time the request out AFTER the
// commit, leaving the event deleted and the sweep half-finished.
// Deleting is idempotent and order-independent, so there is nothing to
// serialise for.
//
// A pool, not Promise.all over every key: an unbounded fan-out would
// open one socket per object and exhaust the S3 client's connection
// pool.
const CONCURRENCY = 16;
let cursor = 0;
const worker = async () => {
while (cursor < keys.length) {
const key = keys[cursor++];
try {
await storage.delete(key);
removed++;
} catch (delErr) {
logger.warn('Failed to delete stored object during cascade delete', {
eventId, key, error: delErr.message
});
}
}
};
await Promise.all(
Array.from({ length: Math.min(CONCURRENCY, keys.length) }, worker)
);
} catch (storageErr) {
logger.warn('Storage backend unavailable during cascade delete', {
eventId, error: storageErr.message
});
}
logger.info('Cascade delete removed stored objects', {
eventId, removed, total: storageKeys.size
});
}
// Audit trail (outside the transaction so a logging failure can't undo
// the actual delete).
await logActivity('event_deleted',
+3 -1
View File
@@ -30,7 +30,9 @@ const eventLogoStorage = multer.diskStorage({
const eventLogoUpload = multer({
storage: eventLogoStorage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
// CVE-2026-82333: single unnamed `logo` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 5MB
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
+2 -2
View File
@@ -8,7 +8,7 @@ const { formatBoolean } = require('../../utils/dbCompat');
const { adminAuth } = require('../../middleware/auth');
const { requirePermission } = require('../../middleware/permissions');
const crypto = require('crypto');
const { errorResponse } = require('../../utils/routeHelpers');
const { errorResponse, safeValidationErrors } = require('../../utils/routeHelpers');
const { parseBooleanInput } = require('../../utils/parsers');
const { requireEventOwnership } = require('../../middleware/ownership');
const { requireFeatureFlag } = require('../../middleware/requireFeatureFlag');
@@ -110,7 +110,7 @@ module.exports = (router) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ error: 'Invalid slideshow settings', details: errors.array() });
return res.status(400).json({ error: 'Invalid slideshow settings', details: safeValidationErrors(errors) });
}
const event = await loadOwnedEvent(req);
+4 -1
View File
@@ -41,7 +41,10 @@ function diskUpload(subdir) {
},
filename: (_req, file, cb) => cb(null, `${subdir.split('/').pop()}-${Date.now()}${path.extname(file.originalname) || ''}`),
}),
limits: { fileSize: 15 * 1024 * 1024 },
// CVE-2026-82333: both callers (`inboundUpload` → 'file', `proofUpload`
// → 'proof') take a single unnamed field — no legitimate array-indexed
// field names, so reject any bracket-index field name.
limits: { fileSize: 15 * 1024 * 1024, fieldArrayIndexLimit: 0 },
fileFilter: (_req, file, cb) => (ALLOWED_MIME.includes(file.mimetype) ? cb(null, true) : cb(new Error('Only PDF, JPEG or PNG files are allowed'))),
});
}
+3 -1
View File
@@ -67,7 +67,9 @@ const importedInvoiceStorage = multer.diskStorage({
});
const importedInvoiceUpload = multer({
storage: importedInvoiceStorage,
limits: { fileSize: 10 * 1024 * 1024 },
// CVE-2026-82333: single unnamed `pdf` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 10 * 1024 * 1024, fieldArrayIndexLimit: 0 },
fileFilter: (_req, file, cb) => {
if (file.mimetype === 'application/pdf') cb(null, true);
else cb(new Error('Only PDF files are allowed for imported invoices'));
+3 -3
View File
@@ -11,7 +11,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
const { PhotoFilterBuilder } = require('../utils/photoFilterBuilder');
const { getPagination } = require('../utils/routeHelpers');
const { getPagination, safeValidationErrors } = require('../utils/routeHelpers');
const { PhotoExportService } = require('../services/photoExportService');
const logger = require('../utils/logger');
@@ -39,7 +39,7 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), re
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const eventId = parseInt(req.params.eventId);
@@ -166,7 +166,7 @@ router.post('/:eventId/export', adminAuth, requirePermission('photos.download'),
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const eventId = parseInt(req.params.eventId);
+31 -65
View File
@@ -14,7 +14,8 @@ const {
} = require('../services/downloadFilenameService');
const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings');
const { getMaxFilesPerUpload, getAllowedMimeTypes, EXTENSION_TO_MIME } = require('../services/uploadSettings');
const { resolvePhotoContentType } = require('../utils/photoContentType');
const { processUploadedPhotos } = require('../services/photoProcessor');
const chunkedUpload = require('../services/chunkedUploadService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
@@ -71,7 +72,12 @@ const upload = multer({
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
parts: 10000,
headerPairs: 2000
headerPairs: 2000,
// CVE-2026-82333: files arrive as repeated `photos` parts via
// .array('photos', N) — not bracket-indexed field names like
// `photos[0]` — so no legitimate field name uses array-index syntax
// at all. Reject any that do.
fieldArrayIndexLimit: 0
},
fileFilter: (req, file, cb) => {
// req.allowedMimeTypes is populated by the middleware that runs before multer
@@ -958,7 +964,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
return res.status(404).json({ error: 'Photo file not found' });
}
res.set({
'Content-Type': photo.mime_type || 'application/octet-stream',
'Content-Type': resolvePhotoContentType(photo),
'Content-Length': stat.size,
'Content-Disposition': contentDisposition,
});
@@ -975,7 +981,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
return res.status(404).json({ error: 'Photo file not found' });
}
res.set({
'Content-Type': photo.mime_type || 'application/octet-stream',
'Content-Type': resolvePhotoContentType(photo),
'Content-Disposition': contentDisposition,
});
res.sendFile(filePath);
@@ -1151,64 +1157,9 @@ router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view
const event = await db('events').where('id', eventId).first();
const storageKey = resolvePhotoStorageKey(event, photo);
// Content-Type resolution (#908 + external review). Invariant: the
// header is ALWAYS image/* or video/*.
// - photos.mime_type is never echoed verbatim unless it is a video/
// type: the chunked-upload path stores the client-sent MIME
// unvalidated, so a stored text/html served inline under the app
// origin would be a same-origin XSS gift.
// - Images ignore the stored value entirely — migration 039
// backfilled image/jpeg onto every legacy row (PNGs included), so
// the extension is the more trustworthy signal; normalized via the
// shared map (image/jpg → image/jpeg), jpeg fallback when unknown.
// - Videos prefer a stored video/ type, then the extension map
// (.mov → video/quicktime, .webm → video/webm, …), then video/mp4.
// The old ext-derived image/<ext> (image/mp4) is what made the
// admin player's blob unplayable (#908).
const { EXTENSION_TO_MIME } = require('../services/uploadSettings');
const ext = path.extname(photo.filename).slice(1).toLowerCase();
// Own-property lookup (review): a client-controlled filename ending in
// .constructor / .__proto__ / .toString would otherwise return an
// inherited Object.prototype member, and the extMime.startsWith below
// would throw — a permanent 500 for that photo instead of the fallback.
const extMime = Object.prototype.hasOwnProperty.call(EXTENSION_TO_MIME, ext)
? EXTENSION_TO_MIME[ext]
: null;
// Full-token validation, not just a prefix check: the stored value is
// client-controlled, and header-invalid characters (video/mp4\r\nX: y)
// would make setHeader throw — a permanent 500 for that photo. Bare
// 'video/' is equally invalid; both fall back to the extension map.
const storedVideoMime = photo.mime_type && /^video\/[\w.+-]+$/.test(photo.mime_type)
? photo.mime_type
: null;
// Honor a stored image MIME for any header-safe RASTER type (#908
// review): the S3 auto-importer accepts arbitrary image/* from
// mime-types and stores it (avif/bmp/tiff/heic/apng/ico/jxl/…), and a
// hand-listed allowlist kept missing formats. Allow image/<token> but
// NEVER the scriptable svg / *+xml family (image/svg+xml executes
// inline). The strict token + anchors also block header injection
// (image/x\r\nY:). Migration 039's blanket image/jpeg backfill on
// legacy rows is why the mapped extension still wins ahead of this.
const storedImageMime =
photo.mime_type &&
/^image\/[\w.+-]+$/.test(photo.mime_type) &&
!/^image\/svg|xml/i.test(photo.mime_type)
? photo.mime_type
: null;
const isVideo = photo.media_type === 'video' ||
Boolean(storedVideoMime) ||
Boolean(extMime && extMime.startsWith('video/'));
// Never interpolate the raw extension on the image side: it would
// synthesize image/svg+xml (scriptable inline) or header-invalid values
// from client-controlled chunked-upload filenames. Precedence is
// mapped-extension (also corrects the 039 legacy-jpeg backfill on PNGs)
// -> safe stored raster MIME (auto-imported avif/bmp/tiff) -> image/jpeg.
// A stored type outside the allowlist degrades to image/jpeg; browsers
// sniff image bytes in <img>/blob contexts, so a mislabel is harmless
// where an injected type is not.
const contentType = isVideo
? storedVideoMime || (extMime && extMime.startsWith('video/') ? extMime : null) || 'video/mp4'
: (extMime && extMime.startsWith('image/') ? extMime : null) || storedImageMime || 'image/jpeg';
// Content-Type resolution (#908 + external review) lives in
// utils/photoContentType so the gallery routes apply the same rule.
const contentType = resolvePhotoContentType(photo);
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'private, max-age=3600');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
@@ -1324,7 +1275,7 @@ router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), requi
router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
try {
const { eventId } = req.params;
const { filename, fileSize, mimeType, totalChunks } = req.body;
const { filename, fileSize, totalChunks } = req.body;
// Validate event exists
const event = await db('events').where({ id: eventId }).first();
@@ -1333,8 +1284,23 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
}
// Validate required fields
if (!filename || !fileSize || !mimeType) {
return res.status(400).json({ error: 'Missing required fields: filename, fileSize, mimeType' });
if (!filename || !fileSize) {
return res.status(400).json({ error: 'Missing required fields: filename, fileSize' });
}
// The client-declared mimeType is not trusted. It used to be stored on
// the photo row verbatim and echoed as Content-Type by the gallery
// routes, so a JPEG/HTML polyglot declared as text/html rendered inline
// on the app origin. The MIME is derived from the extension instead,
// and the extension has to be on the admin's allow-list, which is what
// the multipart path enforces through its multer fileFilter.
const ext = path.extname(String(filename)).slice(1).toLowerCase();
const mimeType = Object.prototype.hasOwnProperty.call(EXTENSION_TO_MIME, ext)
? EXTENSION_TO_MIME[ext]
: null;
const allowedMimeTypes = await getAllowedMimeTypes();
if (!mimeType || !allowedMimeTypes.includes(mimeType)) {
return res.status(400).json({ error: 'File type not allowed' });
}
// Validate file size (max 10GB)
+4 -4
View File
@@ -5,7 +5,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { body, query, validationResult } = require('express-validator');
const logger = require('../utils/logger');
const { getPagination } = require('../utils/routeHelpers');
const { getPagination, safeValidationErrors } = require('../utils/routeHelpers');
const { db } = require('../database/db');
const path = require('path');
const fs = require('fs').promises;
@@ -86,7 +86,7 @@ router.post('/validate', requirePermission('backup.restore'), [
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
errors: safeValidationErrors(errors)
});
}
@@ -158,7 +158,7 @@ router.post('/start', requirePermission('backup.restore'), [
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
errors: safeValidationErrors(errors)
});
}
@@ -690,7 +690,7 @@ router.put('/settings', requirePermission('backup.restore'), [
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
errors: safeValidationErrors(errors)
});
}
+20 -12
View File
@@ -1,6 +1,7 @@
const express = require('express');
const multer = require('multer');
const path = require('path');
const { uploadedAssetPath } = require('../utils/safePath');
const fs = require('fs').promises;
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
@@ -22,7 +23,7 @@ const { sanitizeCss } = require('../utils/cssSanitizer');
const { upsertAppSetting } = require('../utils/appSettings');
const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
const { resetSecurityConfigCache } = require('../utils/authSecurity');
const { errorResponse } = require('../utils/routeHelpers');
const { errorResponse, safeValidationErrors } = require('../utils/routeHelpers');
const logger = require('../utils/logger');
const { measureLocalStorageUsage } = require('../services/localStorageUsage');
const router = express.Router();
@@ -49,7 +50,10 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
const upload = multer({
storage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
// CVE-2026-82333: single unnamed field (`logo` or `watermarkLogo`) per
// route — no legitimate array-indexed field names, so reject any
// bracket-index field name.
limits: { fileSize: 5 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 5MB
fileFilter: (req, file, cb) => {
// Note: SVG files are excluded from magic number validation for logos
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
@@ -77,7 +81,9 @@ const faviconStorage = multer.diskStorage({
const faviconUpload = multer({
storage: faviconStorage,
limits: { fileSize: 2 * 1024 * 1024 }, // 2MB — roomy enough for a 512×512+ square PNG
// CVE-2026-82333: single unnamed `favicon` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 2 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 2MB — roomy enough for a 512×512+ square PNG
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['image/png', 'image/x-icon', 'image/vnd.microsoft.icon'];
const name = file.originalname.toLowerCase();
@@ -567,10 +573,13 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
currentFaviconUrl = currentFaviconSetting.setting_value;
}
if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) {
// Delete the file from filesystem
const relativePath = currentFaviconUrl.replace(/^\//, '');
const faviconPath = path.join(getStoragePath(), relativePath);
// Containment: the stored URL is admin-writable, so only the leaf
// name is used and it is joined onto the fixed favicon directory. A
// prefix test alone let `/uploads/favicons/../../<anything>` pass
// and path.join collapse it -- an arbitrary-file delete for any
// holder of settings.edit.
const faviconPath = uploadedAssetPath(currentFaviconUrl, 'favicons', getStoragePath());
if (faviconPath) {
try {
await fs.unlink(faviconPath);
logger.info('Deleted favicon file:', faviconPath);
@@ -598,10 +607,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
currentLogoUrl = currentLogoSetting.setting_value;
}
if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) {
// Delete the file from filesystem
const relativePath = currentLogoUrl.replace(/^\//, '');
const logoPath = path.join(getStoragePath(), relativePath);
// Same containment as the favicon branch above.
const logoPath = uploadedAssetPath(currentLogoUrl, 'logos', getStoragePath());
if (logoPath) {
try {
await fs.unlink(logoPath);
logger.info('Deleted logo file:', logoPath);
@@ -1474,7 +1482,7 @@ router.put('/security/rate-limit', adminAuth, requirePermission('settings.edit')
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const {
+4 -3
View File
@@ -11,6 +11,7 @@
*/
const express = require('express');
const { body, param, validationResult } = require('express-validator');
const { safeValidationErrors } = require('../utils/routeHelpers');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
@@ -32,7 +33,7 @@ router.get(
requireEventOwnership,
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
try {
const rows = await galleryShortUrlService.listForEvent(parseInt(req.params.eventId, 10));
res.json({ shortUrls: rows });
@@ -56,7 +57,7 @@ router.post(
requireEventOwnership,
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
try {
const row = await galleryShortUrlService.createShortUrl({
eventId: parseInt(req.params.eventId, 10),
@@ -96,7 +97,7 @@ router.delete(
param('id').isInt({ min: 1 }),
async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
try {
const ok = await galleryShortUrlService.softDelete(
parseInt(req.params.id, 10),
+5 -4
View File
@@ -17,6 +17,7 @@
const express = require('express');
const { body, query, validationResult } = require('express-validator');
const { safeValidationErrors } = require('../utils/routeHelpers');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
@@ -106,7 +107,7 @@ router.post(
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
const { name, url, events, active = true, filter, template } = req.body;
const { plaintext, preview } = webhookService.generateSecret();
@@ -188,7 +189,7 @@ router.put(
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
const row = await db('webhooks').where({ id: req.params.id }).first();
if (!row) return res.status(404).json({ error: 'Webhook not found' });
@@ -241,7 +242,7 @@ router.post(
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
const row = await db('webhooks').where({ id: req.params.id }).first();
if (!row) return res.status(404).json({ error: 'Webhook not found' });
@@ -293,7 +294,7 @@ router.get(
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
const webhookId = req.params.id;
const exists = await db('webhooks').where({ id: webhookId }).first();
+55 -15
View File
@@ -2,6 +2,16 @@ const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
/**
* express-validator's errors.array() carries `value` -- the submitted input --
* so returning it verbatim reflects the caller's password back in the 400 body.
* Five routes in this file validate a password field, and the strength endpoint
* is unauthenticated behind a 50mb JSON limit, which also made the rejection
* itself an allocation amplifier. Everything except `value` is kept, so the
* response shape both frontend consumers rely on (`msg`, `path`) is unchanged.
*/
const safeValidationErrors = (errors) => errors.array().map(({ value, ...rest }) => rest);
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
@@ -16,6 +26,9 @@ const {
const { endSession } = require('../middleware/sessionTimeout');
const { revokeToken } = require('../utils/tokenRevocation');
const { timingSafeEqualStr } = require('../utils/timingSafe');
// Well-formed bcrypt hash that matches nothing; compared against when there is
// no account so the unknown-user path costs the same as a wrong password.
const DUMMY_BCRYPT_HASH = '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234';
const logger = require('../utils/logger');
const { errorResponse } = require('../utils/routeHelpers');
const {
@@ -30,6 +43,7 @@ const { getEventShareToken, resolveShareIdentifier } = require('../services/shar
const { getClientIp } = require('../utils/requestIp');
const {
validatePasswordInContext,
MAX_PASSWORD_LENGTH,
getBcryptRounds,
logPasswordValidationFailure
} = require('../utils/passwordValidation');
@@ -80,13 +94,15 @@ async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockout
// Admin login with enhanced security
router.post('/admin/login', [
body('username').notEmpty().trim(),
body('password').notEmpty()
// Length caps: an unbounded username reached the lockout lookup, bcrypt,
// the failed-attempt log line and login_attempts.identifier as sent.
body('username').isString().trim().notEmpty().isLength({ max: 255 }),
body('password').isString().notEmpty().isLength({ max: MAX_PASSWORD_LENGTH })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { username, password, recaptchaToken } = req.body;
@@ -130,7 +146,13 @@ router.post('/admin/login', [
.first();
// Use generic error to prevent user enumeration
if (!admin || !await bcrypt.compare(password, admin.password_hash)) {
// Always run one bcrypt compare so an unknown username costs the same
// ~100ms as a wrong password; short-circuiting here was a timing oracle
// for username enumeration despite the generic message.
const passwordMatches = admin
? await bcrypt.compare(password, admin.password_hash)
: await bcrypt.compare(password, DUMMY_BCRYPT_HASH).then(() => false);
if (!passwordMatches) {
await trackFailedAttempt(username, ipAddress, userAgent);
return res.status(401).json({ error: getGenericAuthError() });
}
@@ -174,7 +196,7 @@ router.post('/admin/login/mfa', [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { mfaToken, code } = req.body;
@@ -309,13 +331,13 @@ router.post('/logout', async (req, res) => {
// Gallery password verification with enhanced security
router.post('/gallery/verify', [
body('slug').notEmpty().trim(),
body('password').optional().isString()
body('slug').isString().trim().notEmpty().isLength({ max: 255 }),
body('password').optional().isString().isLength({ max: MAX_PASSWORD_LENGTH })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { slug, password, recaptchaToken } = req.body;
@@ -327,7 +349,7 @@ router.post('/gallery/verify', [
if (!event) {
// Perform a dummy bcrypt compare to prevent timing-based slug enumeration
await bcrypt.compare(password || '', '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234');
await bcrypt.compare(password || '', DUMMY_BCRYPT_HASH);
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
@@ -421,12 +443,12 @@ router.post('/gallery/verify', [
// Client access login (PIN-based)
router.post('/gallery/:slug/client-login', [
body('password').notEmpty().isString()
body('password').notEmpty().isString().isLength({ max: MAX_PASSWORD_LENGTH })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { slug } = req.params;
@@ -502,7 +524,7 @@ router.post('/gallery/share-login', [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { slug, token } = req.body;
@@ -760,7 +782,7 @@ router.post('/admin/change-password', [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { currentPassword, newPassword } = req.body;
@@ -831,11 +853,27 @@ router.post('/admin/change-password', [
});
// Password strength check endpoint (for real-time validation)
//
// Unauthenticated, and it feeds the request body straight into zxcvbn, whose
// matching is superlinear and synchronous. Without the length bound a single
// request stops the event loop for the whole process -- ~5s at 1,000
// characters and unbounded past that. validatePassword() enforces the same cap
// for every caller; this one keeps the oversized body from being accepted at
// the edge at all.
router.post('/password-strength', [
body('password').notEmpty(),
body('password').isString().isLength({ min: 1, max: MAX_PASSWORD_LENGTH })
.withMessage(`Password must be 1-${MAX_PASSWORD_LENGTH} characters`),
body('context').isIn(['admin', 'gallery']).optional()
], async (req, res) => {
try {
// The validators above only RECORD errors; without this the oversized body
// reached zxcvbn anyway and the endpoint answered 200, so the edge cap was
// decorative. The cap in validatePassword() is still the real control.
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { password, context = 'gallery' } = req.body;
// Get user data if available (for context-aware validation)
@@ -845,7 +883,9 @@ router.post('/password-strength', [
userData.email = req.admin.email;
}
const validation = validatePasswordInContext(password, context, userData);
// validatePasswordInContext is async; unawaited this resolved to a Promise
// and every field below came back undefined.
const validation = await validatePasswordInContext(password, context, userData);
res.json({
valid: validation.valid,
+14 -9
View File
@@ -17,9 +17,10 @@ const jwt = require('jsonwebtoken');
const { body, param, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getBcryptRounds } = require('../utils/passwordValidation');
const { getBcryptRounds, MAX_PASSWORD_LENGTH } = require('../utils/passwordValidation');
const { assertContractPdfPath } = require('../utils/safePath');
const logger = require('../utils/logger');
const { errorResponse } = require('../utils/routeHelpers');
const { errorResponse, safeValidationErrors } = require('../utils/routeHelpers');
const { getClientIp } = require('../utils/requestIp');
const { customerAuth } = require('../middleware/customerAuth');
const { setGalleryAuthCookies } = require('../utils/tokenUtils');
@@ -147,7 +148,7 @@ router.get('/events/:slug/access-token', [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { slug } = req.params;
@@ -284,7 +285,7 @@ router.put('/profile', [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
// Normalise incoming values: trim strings, drop empty → null so the DB
@@ -329,14 +330,14 @@ router.put('/profile', [
*/
router.post('/profile/password', [
customerAuth,
body('currentPassword').isString().isLength({ min: 1 }),
body('newPassword').isString().isLength({ min: 8 })
body('currentPassword').isString().isLength({ min: 1, max: MAX_PASSWORD_LENGTH }),
body('newPassword').isString().isLength({ min: 8, max: MAX_PASSWORD_LENGTH })
.withMessage('Password must be at least 8 characters'),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { currentPassword, newPassword } = req.body;
@@ -706,9 +707,13 @@ router.get('/contracts/:id/pdf', customerAuth, async (req, res) => {
res.set('Content-Disposition', `inline; filename="${contract.contract_number}.pdf"`);
return res.send(buf);
}
// Same containment the admin and public contract routes apply: the DB
// path is written by the service layer today, but a bad row must not
// turn this into an arbitrary-file read.
const safePath = assertContractPdfPath(filePath);
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${path.basename(filePath)}"`);
fs.createReadStream(filePath).pipe(res);
res.set('Content-Disposition', `inline; filename="${path.basename(safePath)}"`);
fs.createReadStream(safePath).pipe(res);
} catch (error) {
errorResponse(res, error, 500, 'Failed to render contract PDF');
}
+15 -7
View File
@@ -16,6 +16,9 @@ const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, param, validationResult } = require('express-validator');
const { safeValidationErrors } = require('../utils/routeHelpers');
const { MAX_PASSWORD_LENGTH } = require('../utils/passwordValidation');
const DUMMY_BCRYPT_HASH = '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234';
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
@@ -65,12 +68,12 @@ const TOKEN_TTL_SECONDS = 24 * 60 * 60; // mirrors admin tokens
// gallery JWTs (instant per-gallery revocation).
router.post('/login', [
body('email').isEmail().normalizeEmail(IDENTITY_PRESERVING_NORMALIZE_EMAIL).withMessage('Valid email is required'),
body('password').isString().notEmpty(),
body('password').isString().notEmpty().isLength({ max: MAX_PASSWORD_LENGTH }),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { email, password, recaptchaToken } = req.body;
@@ -99,7 +102,12 @@ router.post('/login', [
const customer = await db('customer_accounts').where('email', email).first();
// Generic error to prevent user enumeration — same wording as admin login.
if (!customer || !customer.password_hash || !await bcrypt.compare(password, customer.password_hash)) {
// One bcrypt compare on every path so an unknown email is not a timing
// oracle (the dummy hash matches nothing).
const passwordMatches = customer && customer.password_hash
? await bcrypt.compare(password, customer.password_hash)
: await bcrypt.compare(password, DUMMY_BCRYPT_HASH).then(() => false);
if (!passwordMatches) {
await trackFailedAttempt(lockoutKey, ipAddress, userAgent);
return res.status(401).json({ error: getGenericAuthError() });
}
@@ -273,7 +281,7 @@ router.post('/accept-invite', [
// Length floor enforced again here for an early reject; the full
// policy (uppercase + digit) is checked below so we can surface a
// specific message rather than a generic validator error.
body('password').isString().isLength({ min: 8 })
body('password').isString().isLength({ min: 8, max: MAX_PASSWORD_LENGTH })
.withMessage('Password must be at least 8 characters'),
// Optional structured profile from the accept-invite form. Mirrors
// the admin prefill shape — anything the customer types here wins
@@ -296,7 +304,7 @@ router.post('/accept-invite', [
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
const { token, name, password, profile } = req.body;
@@ -355,11 +363,11 @@ router.get('/password-reset/:token', [
*/
router.post('/password-reset', [
body('token').isLength({ min: 64, max: 64 }).matches(/^[a-f0-9]+$/i),
body('password').isString().isLength({ min: 8 }).withMessage('Password must be at least 8 characters'),
body('password').isString().isLength({ min: 8, max: MAX_PASSWORD_LENGTH }).withMessage('Password must be at least 8 characters'),
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
const policyError = validateCustomerPassword(req.body.password);
if (policyError) {
return res.status(400).json({
+277 -40
View File
@@ -10,6 +10,8 @@ const { parseBooleanInput } = require('../utils/parsers');
const { getAppSetting } = require('../utils/appSettings');
const archiver = require('archiver');
const path = require('path');
const { resolvePhotoContentType } = require('../utils/photoContentType');
const { timingSafeEqualStr } = require('../utils/timingSafe');
const router = express.Router();
// #756: a NULL per-event hero_logo_visible means "inherit the global
@@ -30,7 +32,7 @@ const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
const { pipeStreamToResponse } = require('../utils/streamResponse');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
@@ -54,6 +56,43 @@ const fs = require('fs');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/**
* Parse a single-range `Range: bytes=` header against a known size.
*
* Returns null for absent, malformed, multi-range or unsatisfiable headers —
* every one of which the caller answers with a normal 200 full body, which is
* what a client that sent an unparseable range would get today anyway.
* Validating matters because an unchecked parse yields NaN bounds and a 206
* with a nonsense Content-Range, which corrupts a resumed download rather
* than merely failing it.
*/
function parseByteRange(header, size) {
if (!header || typeof header !== 'string' || !size) return null;
const match = /^bytes=(\d*)-(\d*)$/.exec(header.trim());
if (!match) return null;
const [, rawStart, rawEnd] = match;
if (rawStart === '' && rawEnd === '') return null;
let start;
let end;
if (rawStart === '') {
// Suffix form: the last N bytes.
const suffix = parseInt(rawEnd, 10);
if (!suffix) return null;
start = Math.max(0, size - suffix);
end = size - 1;
} else {
start = parseInt(rawStart, 10);
end = rawEnd === '' ? size - 1 : parseInt(rawEnd, 10);
}
if (!Number.isFinite(start) || !Number.isFinite(end)) return null;
if (start > end || start >= size) return null;
return { start, end: Math.min(end, size - 1) };
}
// Check for slug redirect (for renamed events)
async function checkSlugRedirect(slug) {
try {
@@ -130,7 +169,7 @@ router.get('/:slug/verify-token/:token', handleAsync(async (req, res) => {
}
const expectedToken = getEventShareToken(event);
if (token !== expectedToken) {
if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) {
throw new NotFoundError('Gallery', 'Invalid gallery link');
}
@@ -206,7 +245,7 @@ router.get('/:slug/info', async (req, res) => {
// If token provided, verify it matches the share link
if (token) {
const expectedToken = getEventShareToken(event);
if (!expectedToken || token !== expectedToken) {
if (!expectedToken || !timingSafeEqualStr(String(token), expectedToken)) {
return res.status(404).json({ error: 'Invalid gallery link' });
}
}
@@ -996,6 +1035,47 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
}
}
// A HEAD is a metadata probe, not a download. Answering it below the
// counters recorded every probe as a real download, and answering it below
// renderPhotoForDownload fetched and watermarked an image whose body Node
// then discards. Both happen before this point in a GET, so HEAD leaves
// here — with no side effects and no bytes read.
if (req.method === 'HEAD') {
const headUseOriginal = await getUseOriginalFilenames();
const headHeaders = {
'Content-Type': resolvePhotoContentType(photo),
'Content-Disposition': buildContentDisposition(pickRawDownloadName(photo, headUseOriginal)),
'Accept-Ranges': 'bytes',
};
// Content-Length only when the bytes ship untransformed AND the size can
// be read without fetching them. A watermark or resize changes the
// length, and the only way to learn the new one is to do the work this
// branch exists to avoid — HEAD is allowed to omit it.
const headWmSettings = await watermarkService.getWatermarkSettings();
const headWmEnabled = !!(headWmSettings && headWmSettings.enabled)
|| req.event.watermark_downloads === true
|| req.event.watermark_downloads === 1;
if (!headWmEnabled) {
try {
const headKey = resolvePhotoStorageKey(req.event, photo);
const headStorage = getStorage();
if (headKey && headStorage.kind() !== 'local') {
const headStat = await headStorage.stat(headKey);
if (!headStat) return res.status(404).json({ error: 'Photo file not found' });
headHeaders['Content-Length'] = headStat.size;
if (headStat.mtime) headHeaders['Last-Modified'] = new Date(headStat.mtime).toUTCString();
}
} catch (headErr) {
// No length is a valid HEAD; not worth failing the probe over.
logger.debug('HEAD probe could not stat the object', { photoId, error: headErr.message });
}
}
res.set(headHeaders);
return res.end();
}
// Update download count
await db('photos').where('id', photoId).increment('download_count', 1);
@@ -1008,11 +1088,19 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
photo_id: photoId
});
let filePath;
// Where the bytes actually live. Managed photos sit behind the storage
// abstraction and on an S3/R2 deployment are not on local disk at all —
// resolving a filesystem path unconditionally here is what made every
// single-photo download fail in S3 mode, while download-all and
// secure-images worked because they already went through getStorage().
//
// resolvePhotoStorageKey returns null for external/reference photos: those
// live on a local mount and keep the filesystem path.
let storageKey = null;
try {
filePath = resolvePhotoFilePath(req.event, photo);
storageKey = resolvePhotoStorageKey(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path for download', {
logger.error('Failed to resolve photo storage key for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
@@ -1020,7 +1108,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Get watermark settings - apply if global setting OR event-level setting is enabled
const watermarkSettings = await watermarkService.getWatermarkSettings();
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
@@ -1041,35 +1129,170 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
enabled: true,
text: req.event.watermark_text || watermarkSettings?.text || 'Protected'
};
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, effectiveSettings);
// applyWatermark takes a PATH, and caches on it — buffer inputs skip the
// cache deliberately. In S3 mode materialize a tmp local copy and hand
// it the copy's path, exactly as the zip builders below do, so the cache
// still applies and the full-size original isn't re-processed per
// download.
let watermarkedBuffer;
try {
watermarkedBuffer = storageKey
? await withLocalCopy(storageKey, (localPath) =>
watermarkService.applyWatermark(localPath, effectiveSettings))
: await watermarkService.applyWatermark(
resolvePhotoFilePath(req.event, photo), effectiveSettings);
} catch (watermarkError) {
// Classify, the same way the pass-through branch below does. This can
// fail because the source object is gone, but equally because
// getToFile timed out, the tmp filesystem filled up, or sharp failed —
// and reporting an operational failure as 404 tells the guest their
// photo does not exist and tells us nothing.
const gone = watermarkError.code === 'ENOENT'
|| watermarkError.name === 'NoSuchKey'
|| watermarkError.name === 'NotFound'
|| watermarkError.$metadata?.httpStatusCode === 404;
logger.error('Failed to watermark photo for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: watermarkError.message,
});
return gone
? res.status(404).json({ error: 'Photo file not found' })
: res.status(500).json({ error: 'Failed to download photo' });
}
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Type': resolvePhotoContentType(photo),
'Content-Disposition': contentDisposition,
'Content-Length': watermarkedBuffer.length
});
res.send(watermarkedBuffer);
} else {
// res.download() builds Content-Disposition itself but doesn't emit the
// RFC 5987 filename* parameter, so unicode camera filenames would lose
// their bytes on download. Set the header explicitly and stream the
// file with res.sendFile-equivalent semantics.
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Disposition': contentDisposition,
});
res.sendFile(filePath, (downloadError) => {
if (downloadError) {
logger.error('Error streaming gallery download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: downloadError.message,
});
}
});
return res.send(watermarkedBuffer);
}
const storage = getStorage();
if (storageKey && storage.kind() !== 'local') {
// Deliberately NOT the local path: res.sendFile emits Content-Length,
// Accept-Ranges, ETag and Last-Modified and answers Range requests with
// a 206, and a bare stream.pipe(res) has none of that. On local disk
// sendFile stays the better implementation, so it stays the branch.
//
// On S3 the parts that matter for a download are reproduced: the length
// (browsers need it for the progress indicator, which matters most on
// exactly the large files this route serves) and Range, so an
// interrupted download resumes instead of appending a second full body
// onto the partial file.
const stat = await storage.stat(storageKey);
if (!stat) {
logger.error('Photo not found in storage backend for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
storageKey,
});
return res.status(404).json({ error: 'Photo file not found' });
}
const lastModified = stat.mtime ? new Date(stat.mtime).toUTCString() : null;
const headers = {
'Content-Type': resolvePhotoContentType(photo),
'Content-Disposition': contentDisposition,
'Accept-Ranges': 'bytes',
};
if (lastModified) headers['Last-Modified'] = lastModified;
// If-Range: a client resuming an interrupted download sends back the
// validator it was given last time. If the object has been replaced
// since — the watcher re-importing a swapped file, an admin re-upload —
// answering 206 from the NEW bytes lets the client splice two different
// versions into one corrupt file. A validator that doesn't match means
// a full 200, which is the whole point of the header.
const ifRange = req.headers['if-range'];
const staleValidator = !!ifRange && (!lastModified || ifRange.trim() !== lastModified);
const range = staleValidator ? null : parseByteRange(req.headers.range, stat.size);
// Open the stream BEFORE any header is staged or sent. stat() succeeding
// does not mean get() will: a concurrent delete or replace, or a
// transient backend error, lands here. Once writeHead(206) has gone out
// the outer catch can do nothing but throw ERR_HTTP_HEADERS_SENT, and in
// the non-range case it would send its 500 JSON underneath the staged
// image/jpeg attachment headers — a .jpg file full of JSON.
let stream;
try {
stream = range
? await storage.getRange(storageKey, range.start, range.end)
: await storage.get(storageKey);
} catch (fetchError) {
const gone = fetchError.code === 'ENOENT'
|| fetchError.name === 'NoSuchKey'
|| fetchError.name === 'NotFound'
|| fetchError.$metadata?.httpStatusCode === 404;
logger.error('Failed to open photo stream for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
storageKey,
error: fetchError.message,
});
return gone
? res.status(404).json({ error: 'Photo file not found' })
: res.status(500).json({ error: 'Failed to download photo' });
}
if (range) {
// status()+set() rather than writeHead(): writeHead commits the
// response immediately, so a stream that resolves and THEN errors
// before its first chunk would leave pipeStreamToResponse able only to
// destroy the connection. Staged headers are flushed by the first body
// write, which means an error at byte zero can still clear them and
// return a clean, retryable status instead of a transport reset.
res.status(206).set({
...headers,
'Content-Range': `bytes ${range.start}-${range.end}/${stat.size}`,
'Content-Length': (range.end - range.start) + 1,
});
} else {
res.set({ ...headers, 'Content-Length': stat.size });
}
pipeStreamToResponse(stream, res, {
context: range ? `download range for photo ${photo.id}` : `download for photo ${photo.id}`,
});
return;
}
let filePath;
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
}
// res.download() builds Content-Disposition itself but doesn't emit the
// RFC 5987 filename* parameter, so unicode camera filenames would lose
// their bytes on download. Set the header explicitly and stream the
// file with res.sendFile-equivalent semantics.
res.set({
'Content-Type': resolvePhotoContentType(photo),
'Content-Disposition': contentDisposition,
});
res.sendFile(filePath, (downloadError) => {
if (downloadError) {
logger.error('Error streaming gallery download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: downloadError.message,
});
}
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to download photo');
}
@@ -1596,25 +1819,34 @@ router.get('/:slug/photo/:photoId',
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunksize = (end - start) + 1;
// Validate before writing the 206: a NaN, inverted or out-of-file
// range used to be committed to the headers and then throw while
// streaming (or read past the end).
if (!Number.isInteger(start) || !Number.isInteger(end)
|| start < 0 || end < start || start >= fileSize) {
res.set('Content-Range', `bytes */${fileSize}`);
return res.status(416).end();
}
const boundedEnd = Math.min(end, fileSize - 1);
const chunksize = (boundedEnd - start) + 1;
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Content-Range': `bytes ${start}-${boundedEnd}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': photo.mime_type || 'video/mp4',
'Content-Type': resolvePhotoContentType(photo),
'Cache-Control': 'private, max-age=1800',
'X-Protection-Level': 'basic'
});
const file = useStorageBackend
? await storage.getRange(storageKey, start, end)
: fs.createReadStream(filePath, { start, end });
? await storage.getRange(storageKey, start, boundedEnd)
: fs.createReadStream(filePath, { start, end: boundedEnd });
pipeStreamToResponse(file, res, { context: `video range for photo ${photo.id}` });
} else {
res.writeHead(200, {
'Content-Length': fileSize,
'Content-Type': photo.mime_type || 'video/mp4',
'Content-Type': resolvePhotoContentType(photo),
'Accept-Ranges': 'bytes',
'Cache-Control': 'private, max-age=1800',
'X-Protection-Level': 'basic'
@@ -1649,7 +1881,7 @@ router.get('/:slug/photo/:photoId',
const wmStat = await storage.stat(photo.watermark_path);
if (wmStat) {
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Type': resolvePhotoContentType(photo),
'Content-Length': wmStat.size,
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
@@ -1662,7 +1894,7 @@ router.get('/:slug/photo/:photoId',
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
if (fs.existsSync(watermarkFilePath)) {
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Type': resolvePhotoContentType(photo),
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
@@ -1688,7 +1920,7 @@ router.get('/:slug/photo/:photoId',
.catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message));
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Type': resolvePhotoContentType(photo),
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
@@ -1703,7 +1935,7 @@ router.get('/:slug/photo/:photoId',
});
if (useStorageBackend) {
res.set('Content-Length', stat.size);
if (photo.mime_type) res.set('Content-Type', photo.mime_type);
res.set('Content-Type', resolvePhotoContentType(photo));
const stream = await storage.get(storageKey);
pipeStreamToResponse(stream, res, { context: `photo ${photo.id}` });
} else {
@@ -2110,7 +2342,12 @@ router.post('/:eventId/upload', verifyGalleryAccess, denySlideshowToken, async (
dest: tempUploadDir,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB per file (separate concern from #613)
files: maxFilesPerUpload
files: maxFilesPerUpload,
// CVE-2026-82333: files arrive as repeated `photos` parts via
// .array(), not bracket-indexed field names like `photos[0]` — no
// legitimate field name uses array-index syntax at all. Reject any
// that do.
fieldArrayIndexLimit: 0
},
fileFilter: (req, file, cb) => {
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
+3 -2
View File
@@ -1,4 +1,5 @@
const express = require('express');
const { resolvePhotoContentType } = require('../utils/photoContentType');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyGalleryAccess } = require('../middleware/gallery');
@@ -165,7 +166,7 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
// Set security headers
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Type': resolvePhotoContentType(photo),
'Content-Length': finalImage.length,
'Cache-Control': 'private, no-cache, no-store, must-revalidate',
'Pragma': 'no-cache',
@@ -335,7 +336,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
// Set appropriate headers
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Type': resolvePhotoContentType(photo),
'Content-Length': imageBuffer.length,
'Cache-Control': 'private, max-age=3600',
'X-Content-Type-Options': 'nosniff'
+4 -1
View File
@@ -62,7 +62,10 @@ const signedPdfStorage = multer.diskStorage({
const signedPdfUpload = multer({
storage: signedPdfStorage,
limits: { fileSize: 10 * 1024 * 1024 }, // 10 MB
// CVE-2026-82333: single unnamed `file` field only, and this route is
// unauthenticated (token-only) — no legitimate array-indexed field
// names, so reject any bracket-index field name.
limits: { fileSize: 10 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 10 MB
fileFilter: (req, file, cb) => {
if (validateFileType(file.originalname, file.mimetype, ['application/pdf'])) return cb(null, true);
return cb(new Error('Only PDF files are allowed'));
+3 -2
View File
@@ -1,4 +1,5 @@
const express = require('express');
const { resolvePhotoContentType } = require('../utils/photoContentType');
const { db } = require('../database/db');
const { verifyGalleryAccess, denySlideshowToken } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService');
@@ -236,7 +237,7 @@ router.get('/:slug/secure/:photoId/:token',
// Set content type and security headers
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Type': resolvePhotoContentType(photo),
'Content-Length': processedImage.length,
'X-Protection-Level': protectionSettings.protectionLevel,
'X-Remaining-Uses': tokenValidation.remaining
@@ -425,7 +426,7 @@ router.get('/:slug/secure-download/:photoId/:token',
const downloadName = pickRawDownloadName(photo, useOriginal);
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Type': resolvePhotoContentType(photo),
'Content-Disposition': buildContentDisposition(downloadName),
'Content-Length': fileBuffer.length,
'X-Download-Protected': 'true'
+5 -3
View File
@@ -7,6 +7,8 @@
// rate-limited at the mount point in server.js (authRateLimiter).
const express = require('express');
const { body, validationResult } = require('express-validator');
const { safeValidationErrors } = require('../utils/routeHelpers');
const { MAX_PASSWORD_LENGTH } = require('../utils/passwordValidation');
const setupService = require('../services/setupService');
const { getClientIp } = require('../utils/requestIp');
const { setAdminAuthCookie } = require('../utils/tokenUtils');
@@ -31,7 +33,7 @@ router.post('/verify-token', [
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
try {
const valid = await setupService.verifySetupToken(req.body.token);
@@ -51,11 +53,11 @@ router.post('/verify-token', [
router.post('/admin', [
body('token').notEmpty().withMessage('Setup token is required'),
body('email').isEmail().withMessage('A valid email is required'),
body('password').notEmpty().withMessage('Password is required'),
body('password').isString().notEmpty().isLength({ max: MAX_PASSWORD_LENGTH }).withMessage('Password is required'),
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
return res.status(400).json({ errors: safeValidationErrors(errors) });
}
try {
const { token, email, password } = req.body;
+5 -2
View File
@@ -18,6 +18,7 @@ const crypto = require('crypto');
const multer = require('multer');
const sharp = require('sharp');
const { body, query, validationResult } = require('express-validator');
const { safeValidationErrors } = require('../../utils/routeHelpers');
const { db, logActivity } = require('../../database/db');
const { apiTokenAuth, requireApiScope } = require('../../middleware/apiTokenAuth');
const { requireEventOwnership, scopeEventsQuery } = require('../../middleware/ownership');
@@ -56,7 +57,9 @@ const photoStorage = multer.diskStorage({
});
const photoUpload = multer({
storage: photoStorage,
limits: { fileSize: 100 * 1024 * 1024 }, // 100MB per file for v1
// CVE-2026-82333: single unnamed `photo` field only — no legitimate
// array-indexed field names, so reject any bracket-index field name.
limits: { fileSize: 100 * 1024 * 1024, fieldArrayIndexLimit: 0 }, // 100MB per file for v1
fileFilter: (_req, file, cb) => {
if (/^image\//.test(file.mimetype)) cb(null, true);
else cb(new Error('Only image uploads are accepted on this endpoint'));
@@ -146,7 +149,7 @@ router.post(
async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
if (!errors.isEmpty()) return res.status(400).json({ errors: safeValidationErrors(errors) });
const {
event_name, event_type, event_date,
customer_name = null, customer_email = null, customer_phone = null,
@@ -1,4 +1,3 @@
const { DatabaseBackupService } = require('../databaseBackup');
const { db } = require('../../database/db');
const fs = require('fs').promises;
const path = require('path');
@@ -9,6 +8,10 @@ jest.mock('../../database/db');
jest.mock('../../utils/logger');
jest.mock('../emailProcessor');
jest.mock('child_process');
jest.mock('node-cron', () => ({ schedule: jest.fn(() => ({ stop: jest.fn() })) }));
const { DatabaseBackupService, startScheduledBackups, databaseBackupService, isUnderPubliclyServableRoot } = require('../databaseBackup');
const cron = require('node-cron');
describe('DatabaseBackupService', () => {
let service;
@@ -188,6 +191,213 @@ describe('DatabaseBackupService', () => {
});
});
describe('backup() destination path resolution (#1365)', () => {
// getBackupConfig() returns database_backup_*-prefixed keys.
// Regression: backup() used to destructure the unprefixed names
// (`destinationPath`, ...) straight off that object, which never
// matched, so the configured path was silently ignored and every
// run tried to create the hardcoded /backup/database default.
it('creates the directory from database_backup_destination_path when configured', async () => {
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_destination_path', setting_value: JSON.stringify('/data/db-backups') }
])
});
const stop = new Error('stop after mkdir — nothing past it matters for this test');
const mkdirSpy = jest.spyOn(fs, 'mkdir').mockRejectedValue(stop);
await expect(service.backup({})).rejects.toThrow(stop.message);
expect(mkdirSpy).toHaveBeenCalledWith('/data/db-backups', { recursive: true });
mkdirSpy.mockRestore();
});
it('falls back to /backup/database only when nothing is configured', async () => {
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([])
});
const stop = new Error('stop after mkdir');
const mkdirSpy = jest.spyOn(fs, 'mkdir').mockRejectedValue(stop);
await expect(service.backup({})).rejects.toThrow(stop.message);
expect(mkdirSpy).toHaveBeenCalledWith('/backup/database', { recursive: true });
mkdirSpy.mockRestore();
});
});
describe('isUnderPubliclyServableRoot (GHSA-jw8m class, #1365)', () => {
const originalStoragePath = process.env.STORAGE_PATH;
const storage = '/tmp/picpeak-test-storage';
beforeEach(() => {
process.env.STORAGE_PATH = storage;
});
afterAll(() => {
if (originalStoragePath === undefined) {
delete process.env.STORAGE_PATH;
} else {
process.env.STORAGE_PATH = originalStoragePath;
}
});
it.each([
path.join(storage, 'uploads', 'logos'),
path.join(storage, 'uploads', 'logos', 'sub'),
path.join(storage, 'uploads', 'favicons'),
path.join(storage, 'fonts'),
path.join(storage, 'fonts', 'inter'),
// Bundled fallback fonts — nodejs-owned per the Dockerfile's
// COPY --chown, and served at the same public /fonts route.
path.resolve(__dirname, '../../../assets/fonts'),
// Case-insensitive-but-preserving filesystems (APFS, NTFS, Docker
// Desktop bind mounts of either) resolve this to the same directory
// as uploads/logos even though path.resolve() never folds case.
path.join(storage, 'UPLOADS', 'Logos')
])('flags %s as publicly servable', (candidate) => {
expect(isUnderPubliclyServableRoot(candidate)).toBe(true);
});
it.each([
path.join(storage, 'backups'),
path.join(storage, 'uploads', 'contracts', 'signed'),
path.join(storage, 'uploads', 'transfers', '123'),
'/data/db-backups'
])('does not flag %s', (candidate) => {
expect(isUnderPubliclyServableRoot(candidate)).toBe(false);
});
it('backup() refuses a destination inside a publicly servable root without ever calling mkdir', async () => {
const publicPath = path.join(storage, 'uploads', 'logos');
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_destination_path', setting_value: JSON.stringify(publicPath) }
])
});
const mkdirSpy = jest.spyOn(fs, 'mkdir');
await expect(service.backup({})).rejects.toThrow('publicly served directory');
expect(mkdirSpy).not.toHaveBeenCalled();
mkdirSpy.mockRestore();
});
it('flags FRONTEND_DIR — the all-in-one image serves its built SPA unauthenticated', () => {
const originalFrontendDir = process.env.FRONTEND_DIR;
process.env.FRONTEND_DIR = '/app/frontend/dist';
try {
expect(isUnderPubliclyServableRoot('/app/frontend/dist')).toBe(true);
expect(isUnderPubliclyServableRoot(path.join('/app/frontend/dist', 'assets'))).toBe(true);
} finally {
if (originalFrontendDir === undefined) delete process.env.FRONTEND_DIR;
else process.env.FRONTEND_DIR = originalFrontendDir;
}
});
it('resolves a symlinked alias of a public root to the same real directory (all-in-one /app/storage -> /data/storage)', async () => {
const os = require('os');
const realRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-real-'));
const linkRoot = path.join(os.tmpdir(), `picpeak-link-${process.pid}-${Date.now()}`);
await fs.mkdir(path.join(realRoot, 'uploads', 'logos'), { recursive: true });
await fs.symlink(realRoot, linkRoot, 'dir');
try {
// STORAGE_PATH (what the guard's roots are built from) is the real
// path; the attacker-supplied destination goes through the symlink
// — exactly the all-in-one image's /app/storage -> /data/storage.
process.env.STORAGE_PATH = realRoot;
const aliased = path.join(linkRoot, 'uploads', 'logos');
expect(isUnderPubliclyServableRoot(aliased)).toBe(true);
} finally {
await fs.unlink(linkRoot);
await fs.rm(realRoot, { recursive: true, force: true });
}
});
});
describe('startScheduledBackups (#1365)', () => {
// Same key-mismatch bug as backup(): getBackupConfig() returns
// database_backup_*-prefixed keys, but this read `config.enabled` /
// `config.schedule` / `config.retentionDays` — always undefined, so
// the scheduler silently treated every install as disabled.
it('does not start the schedule while database_backup_enabled is false', async () => {
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_enabled', setting_value: 'false' }
])
});
await startScheduledBackups();
expect(cron.schedule).not.toHaveBeenCalled();
});
it('starts the schedule with the configured cron when database_backup_enabled is true', async () => {
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
{ setting_key: 'database_backup_schedule', setting_value: JSON.stringify('0 4 * * *') }
])
});
await startScheduledBackups();
expect(cron.schedule).toHaveBeenCalledWith('0 4 * * *', expect.any(Function));
});
it('re-reads retention on every tick instead of the value captured at schedule start (#1365)', async () => {
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
{ setting_key: 'database_backup_retention_days', setting_value: JSON.stringify(30) }
])
});
await startScheduledBackups();
const tick = cron.schedule.mock.calls[0][1];
// A /config update between schedule-start and this tick raised
// retention to 365 — the closed-over 30 must not be what runs.
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue([
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
{ setting_key: 'database_backup_retention_days', setting_value: JSON.stringify(365) }
])
});
jest.spyOn(databaseBackupService, 'backup').mockResolvedValue({ success: true });
const cleanupSpy = jest.spyOn(databaseBackupService, 'cleanupOldBackups').mockResolvedValue(undefined);
await tick();
expect(cleanupSpy).toHaveBeenCalledWith(365);
jest.restoreAllMocks();
});
});
describe('cleanupOldBackups destructive-retention guard (#1365)', () => {
it.each([-1, 0, NaN, Infinity])('refuses retentionDays=%s without touching the database', async (bad) => {
const dbSpy = jest.fn();
db.mockImplementation(dbSpy);
await service.cleanupOldBackups(bad);
expect(dbSpy).not.toHaveBeenCalled();
});
});
describe('cleanupOldBackups', () => {
it('should delete old backup files and records', async () => {
const oldBackups = [
@@ -0,0 +1,125 @@
jest.mock('../../utils/logger');
jest.mock('fluent-ffmpeg');
jest.mock('../storage', () => ({
getStorage: jest.fn()
}));
jest.mock('../imageProcessor', () => ({
generateVideoPlaceholder: jest.fn(),
DEFAULT_THUMBNAIL_WIDTH: 300,
DEFAULT_THUMBNAIL_HEIGHT: 300
}));
const ffmpeg = require('fluent-ffmpeg');
const { getStorage } = require('../storage');
const { generateVideoPlaceholder } = require('../imageProcessor');
const {
extractVideoMetadata,
processUploadedVideo
} = require('../videoProcessor');
describe('extractVideoMetadata (#1370)', () => {
afterEach(() => jest.clearAllMocks());
it('returns null duration rather than 0 when ffprobe has none, so "unknown" and "a real 0s clip" stay distinguishable', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
cb(null, {
streams: [{ codec_type: 'video', width: 1920, height: 1080, codec_name: 'hevc' }],
format: {} // no duration field at all
});
});
const metadata = await extractVideoMetadata('/tmp/video.mp4');
expect(metadata.duration).toBeNull();
expect(metadata.width).toBe(1920);
expect(metadata.videoCodec).toBe('hevc');
});
it('floors a real duration', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
cb(null, { streams: [], format: { duration: 12.9 } });
});
const metadata = await extractVideoMetadata('/tmp/video.mp4');
expect(metadata.duration).toBe(12);
});
});
describe('processUploadedVideo degrades gracefully instead of rejecting the whole video (#1370)', () => {
let storage;
beforeEach(() => {
storage = { putFromFile: jest.fn().mockResolvedValue(undefined), exists: jest.fn().mockResolvedValue(true) };
getStorage.mockReturnValue(storage);
generateVideoPlaceholder.mockResolvedValue('thumbnails/thumb_placeholder.jpg');
});
afterEach(() => jest.clearAllMocks());
it('keeps the thumbnail when only metadata extraction fails', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => cb(new Error('moov atom not found')));
ffmpeg.mockImplementation(() => ({
screenshots: jest.fn(function screenshots({ filename, folder }) {
require('fs').writeFileSync(require('path').join(folder, filename), 'jpeg-bytes');
return this;
}),
on(event, handler) {
if (event === 'end') setImmediate(handler);
return this;
}
}));
const result = await processUploadedVideo('/tmp/video.mp4', 'thumbnails/thumb_video.jpg');
expect(result.success).toBe(true);
expect(result.metadata).toBeNull();
expect(result.thumbnailKey).toBe('thumbnails/thumb_video.jpg');
// A real thumbnail already succeeded — never touch the placeholder path.
expect(generateVideoPlaceholder).not.toHaveBeenCalled();
});
it('falls back to the SVG placeholder when thumbnail generation fails, so the gallery never falls back to rendering the raw video as an <img> (codex review)', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => {
cb(null, {
streams: [{ codec_type: 'video', width: 1080, height: 1920, codec_name: 'h264' }],
format: { duration: 5.4 }
});
});
ffmpeg.mockImplementation(() => ({
screenshots() { return this; },
on(event, handler) {
if (event === 'error') setImmediate(() => handler(new Error('ffmpeg seek failed')));
return this;
}
}));
const result = await processUploadedVideo('/tmp/video.mp4', 'thumbnails/thumb_wedding_001.jpg');
expect(result.success).toBe(true);
expect(result.metadata).toEqual(expect.objectContaining({ duration: 5, videoCodec: 'h264' }));
// thumbnailKey is always thumbnails/thumb_<name>.jpg — strip the prefix
// back to a filename so generateVideoPlaceholder recomputes the same key.
// Explicit width/height so generateVideoPlaceholder skips its DB-backed
// settings lookup — this can run inside an open per-file SQLite
// transaction (chunked video upload), where that lookup deadlocks.
expect(generateVideoPlaceholder).toHaveBeenCalledWith('wedding_001.jpg', { width: 300, height: 300 });
expect(result.thumbnailKey).toBe('thumbnails/thumb_placeholder.jpg');
expect(storage.putFromFile).not.toHaveBeenCalled();
});
it('throws when metadata, thumbnail generation, AND the placeholder all fail, so the caller surfaces a retryable failure instead of completing with nothing to show (codex review)', async () => {
ffmpeg.ffprobe = jest.fn((videoPath, cb) => cb(new Error('Invalid data found when processing input')));
ffmpeg.mockImplementation(() => ({
screenshots() { return this; },
on(event, handler) {
if (event === 'error') setImmediate(() => handler(new Error('ffmpeg seek failed')));
return this;
}
}));
generateVideoPlaceholder.mockRejectedValue(new Error('sharp render failed'));
await expect(processUploadedVideo('/tmp/corrupt.mp4', 'thumbnails/thumb_corrupt.jpg'))
.rejects.toThrow('Unable to generate any thumbnail');
});
});
+1 -1
View File
@@ -1043,7 +1043,7 @@ function buildManifestFiles(backedUpFiles, allFiles) {
async function saveManifestToLocal(manifest, manifestFileName, config) {
const manifestDir = config.backup_manifest_path
|| path.join(config.backup_destination_path || '/backup', 'manifests');
|| path.join(config.backup_destination_path || path.join(getStoragePath(), 'backups'), 'manifests');
await fs.mkdir(manifestDir, { recursive: true });
const manifestPath = path.join(manifestDir, manifestFileName);
await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json');
+116 -13
View File
@@ -4,7 +4,7 @@ const crypto = require('crypto');
const { spawnAsync, spawnToFile } = require('../utils/safeExec');
const zlib = require('zlib');
const { pipeline } = require('stream/promises');
const { createReadStream, createWriteStream } = require('fs');
const { createReadStream, createWriteStream, realpathSync } = require('fs');
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const logger = require('../utils/logger');
@@ -16,6 +16,76 @@ const packageJson = require('../../package.json');
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks for streaming
const PROGRESS_INTERVAL = 100; // Report progress every 100 rows
function getStoragePath() {
return process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
}
// Public, unauthenticated static mounts (server.js) that must never become a
// backup destination — a dump landing there is downloadable by anyone who
// learns or guesses the filename, GHSA-jw8m-43r2-jqrm's exact class. Before
// #1365, `database_backup_destination_path` was silently ignored (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 that exact
// exfiltration path unless it's rejected here too.
function getPubliclyServableRoots() {
const storage = getStoragePath();
return [
path.join(storage, 'uploads', 'logos'),
path.join(storage, 'uploads', 'favicons'),
path.join(storage, 'fonts'),
// Bundled fallback fonts (server.js mounts both at /fonts, storage wins
// on overlap but express.static falls through to this one on a miss).
// COPY --chown=nodejs:nodejs in the Dockerfile makes this nodejs-owned
// and therefore writable at runtime, not just a read-only image layer.
path.resolve(__dirname, '../../assets/fonts'),
// The all-in-one image's built frontend bundle (Dockerfile.aio ships it
// nodejs-owned) — server.js serves it unauthenticated as the SPA itself.
process.env.FRONTEND_DIR || path.resolve(__dirname, '../../../frontend/dist')
];
}
// Resolves symlinks in whatever prefix of candidatePath currently exists,
// then re-appends any not-yet-created remainder literally. A plain
// fs.realpathSync would throw ENOENT for the common case where the backup
// destination doesn't exist yet; a plain path.resolve() would miss the
// all-in-one image's `/app/storage -> /data/storage` symlink (Dockerfile.aio),
// which lets `/app/storage/uploads/logos` alias the real public logos
// directory under a name that never lexically matches it.
function resolveRealish(candidatePath) {
let current = path.resolve(candidatePath);
const remainder = [];
for (;;) {
try {
const real = realpathSync(current);
return remainder.length ? path.join(real, ...remainder) : real;
} catch (error) {
if (error.code !== 'ENOENT') {
return path.resolve(candidatePath);
}
const parent = path.dirname(current);
if (parent === current) {
return path.resolve(candidatePath);
}
remainder.unshift(path.basename(current));
current = parent;
}
}
}
function isUnderPubliclyServableRoot(candidatePath) {
// Lowercased comparison: on a case-insensitive-but-preserving filesystem
// (default macOS APFS, NTFS, and Docker Desktop's bind-mount passthrough
// of either) `STORAGE_PATH/UPLOADS/logos` and `.../uploads/logos` name the
// same directory on disk even though path.resolve() never folds case.
const resolved = resolveRealish(candidatePath).toLowerCase();
return getPubliclyServableRoots().some((root) => {
const resolvedRoot = resolveRealish(root).toLowerCase();
return resolved === resolvedRoot || resolved.startsWith(resolvedRoot + path.sep);
});
}
/**
* Database Backup Service
* Supports both SQLite and PostgreSQL with proper escaping,
@@ -296,15 +366,33 @@ class DatabaseBackupService {
let backupRun = null;
try {
// Get configuration
// Get configuration. getBackupConfig() returns the raw
// database_backup_*-prefixed setting keys, not the unprefixed
// names used internally below — map them explicitly rather than
// spreading `config` straight into the destructure, which silently
// matched nothing and always fell through to the hardcoded
// defaults (notably `/backup/database`, regardless of what was
// configured).
const config = await this.getBackupConfig();
const {
destinationPath = '/backup/database',
compress = true,
validateIntegrity = true,
includeChecksums = true
} = { ...config, ...options };
} = {
destinationPath: config.database_backup_destination_path,
compress: config.database_backup_compress,
validateIntegrity: config.database_backup_validate_integrity,
includeChecksums: config.database_backup_include_checksums,
...options
};
if (isUnderPubliclyServableRoot(destinationPath)) {
throw new Error(
`Refusing to write a database backup to a publicly served directory: ${destinationPath}`
);
}
// Create backup directory
await fs.mkdir(destinationPath, { recursive: true });
@@ -423,7 +511,7 @@ class DatabaseBackupService {
logger.info(`Database backup completed: ${finalFile} (${(finalStats.size / 1024 / 1024).toFixed(2)} MB) in ${durationSeconds}s`);
// Send success notification if configured
if (config.emailOnSuccess) {
if (config.database_backup_email_on_success) {
await this.sendBackupNotification('success', {
duration: durationSeconds,
size: finalStats.size,
@@ -457,7 +545,7 @@ class DatabaseBackupService {
// Send failure notification
const config = await this.getBackupConfig();
if (config.emailOnFailure) {
if (config.database_backup_email_on_failure) {
await this.sendBackupNotification('failure', {
error: error.message
});
@@ -538,10 +626,19 @@ class DatabaseBackupService {
* Clean up old backups
*/
async cleanupOldBackups(retentionDays = 30) {
// A zero/negative/non-finite value pushes the cutoff to today or the
// future, matching (and deleting) every completed backup — including
// the one a scheduled run just created. Defense in depth: PUT /config
// already rejects such values, but this is also reachable with
// whatever database_backup_retention_days happens to be persisted.
if (!Number.isFinite(retentionDays) || retentionDays < 1) {
logger.error(`Refusing to clean up backups with invalid retentionDays: ${retentionDays}`);
return;
}
try {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
// Get old backup records
const oldBackups = await db('database_backup_runs')
.where('completed_at', '<', cutoffDate)
@@ -686,25 +783,30 @@ async function startScheduledBackups() {
try {
const config = await databaseBackupService.getBackupConfig();
if (!config.enabled) {
if (!config.database_backup_enabled) {
logger.info('Database backup service is disabled');
return;
}
// Stop existing schedule
if (backupSchedule) {
backupSchedule.stop();
}
// Default schedule: 3 AM daily (offset from file backups at 2 AM)
const schedule = config.schedule || '0 3 * * *';
const schedule = config.database_backup_schedule || '0 3 * * *';
backupSchedule = cron.schedule(schedule, async () => {
logger.info('Starting scheduled database backup');
try {
await databaseBackupService.backup();
await databaseBackupService.cleanupOldBackups(config.retentionDays || 30);
// Re-read retention on every tick rather than closing over the value
// from schedule start — a retention-only /config update doesn't
// restart the schedule (only enabled/schedule changes do), so the
// closed-over value would otherwise run stale until next restart.
const latestConfig = await databaseBackupService.getBackupConfig();
await databaseBackupService.cleanupOldBackups(latestConfig.database_backup_retention_days || 30);
} catch (error) {
logger.error('Scheduled database backup failed:', error);
}
@@ -731,5 +833,6 @@ module.exports = {
databaseBackupService,
startScheduledBackups,
stopScheduledBackups,
isUnderPubliclyServableRoot,
DatabaseBackupService // Export class for testing
};
+12 -1
View File
@@ -168,8 +168,19 @@ async function formatEventDate(value) {
}
}
// Draft, archived and deactivated galleries are refused by /info; the OG
// preview must not leak their name, date and welcome message to crawlers.
function isPubliclyVisible(event) {
if (!event) return false;
const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true';
if (truthy(event.is_draft) || truthy(event.is_archived)) return false;
if (event.is_active === false || event.is_active === 0 || event.is_active === '0') return false;
return true;
}
async function buildOgMetadata(slug, requestPath) {
const event = await resolveSlug(slug);
const resolved = await resolveSlug(slug);
const event = isPubliclyVisible(resolved) ? resolved : null;
const branding = await fetchBranding();
const base = frontendBase();
const siteName = branding.companyName || 'PicPeak';
+11 -3
View File
@@ -369,9 +369,15 @@ async function generateVideoPlaceholder(originalFilename, options = {}) {
const thumbnailRelKey = path.posix.join('thumbnails', thumbnailFilename);
const storage = getStorage();
const settings = await getThumbnailSettings();
const width = settings.width || DEFAULT_THUMBNAIL_WIDTH;
const height = settings.height || DEFAULT_THUMBNAIL_HEIGHT;
// Skip the settings lookup when the caller already supplies dimensions.
// This can run from inside an open per-file SQLite transaction (chunked
// video upload's fallback path in videoProcessor.js) — a second,
// un-transacted db() query for settings there deadlocks against SQLite's
// single-connection pool until acquireConnectionTimeout (60s), reproduced
// directly against an isolated SQLite db (codex review of #1371/#1372).
const settings = (options.width && options.height) ? {} : await getThumbnailSettings();
const width = options.width || settings.width || DEFAULT_THUMBNAIL_WIDTH;
const height = options.height || settings.height || DEFAULT_THUMBNAIL_HEIGHT;
if (options.regenerate) {
await storage.delete(thumbnailRelKey).catch(() => {});
@@ -864,4 +870,6 @@ module.exports = {
ensurePreviewImage,
extractCaptureDate,
withLocalCopy,
DEFAULT_THUMBNAIL_WIDTH,
DEFAULT_THUMBNAIL_HEIGHT,
};
+7 -2
View File
@@ -106,8 +106,13 @@ function isAuthenticated(req) {
return false;
}
// Valid token found - check type
req.tokenType = decoded.type; // 'admin' or 'gallery'
// Only an admin session earns the skip. A gallery token is minted for
// free on password-less galleries and slideshow links, so treating it as
// "authenticated" handed anyone an unlimited budget on every /api route.
if (decoded.type !== 'admin') {
return false;
}
req.tokenType = decoded.type;
req.tokenPayload = decoded;
return true;
+99 -23
View File
@@ -32,7 +32,10 @@ async function extractVideoMetadata(videoPath) {
const audioStream = metadata.streams.find(s => s.codec_type === 'audio');
const result = {
duration: Math.floor(metadata.format.duration || 0),
// null (not 0) when ffprobe genuinely has no duration — a real
// 0-second clip and "unknown" must stay distinguishable, since
// downstream code treats `duration != null` as "trust this value".
duration: metadata.format.duration != null ? Math.floor(metadata.format.duration) : null,
width: videoStream?.width || null,
height: videoStream?.height || null,
videoCodec: videoStream?.codec_name || null,
@@ -130,35 +133,108 @@ async function getVideoDuration(videoPath) {
* Process an uploaded video: extract metadata and produce a thumbnail through
* the storage backend.
*
* Metadata extraction and thumbnail generation are independent, best-effort
* steps mirroring how the image pipeline treats thumbnail/dimension/EXIF
* failures (log a warning, keep the upload). This used to gate 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 (#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.
* Trying both steps independently means a real thumbnail (and whatever
* metadata ffprobe *can* read) survives far more often. metadata is still
* allowed to come back null (ffprobe failed) a video with no thumbnail
* would fall back to rendering the raw video as an <img> in the gallery
* grid (`photo.thumbnail_url || photo.url`), so this only resolves when a
* real thumbnail or the SVG placeholder produced *something*; if both fail
* (storage backend down, disk full not a quirk of one file) it throws
* instead, so the caller surfaces a retryable failure rather than silently
* completing with nothing to show.
*
* @param {string} videoPath - Local path to the source video (ffmpeg requires fs).
* @param {string} thumbnailKey - Relative storage key for the thumbnail.
* @returns {Promise<{success: boolean, metadata: Object, thumbnailKey: string}>}
* @returns {Promise<{success: boolean, metadata: Object|null, thumbnailKey: string}>}
*/
async function processUploadedVideo(videoPath, thumbnailKey, options = {}) {
let metadata = null;
try {
const isValid = await isValidVideo(videoPath);
if (!isValid) {
throw new Error('Invalid video file');
}
const metadata = await extractVideoMetadata(videoPath);
await generateVideoThumbnail(videoPath, thumbnailKey, options);
const storage = getStorage();
const exists = await storage.exists(thumbnailKey);
if (!exists) {
throw new Error('Thumbnail generation failed (not in storage)');
}
return {
success: true,
metadata,
thumbnailKey
};
metadata = await extractVideoMetadata(videoPath);
} catch (error) {
logger.error('Error processing video', { error: error.message, videoPath });
throw error;
logger.error('Video metadata extraction failed — continuing without duration/codec/dimensions', {
error: error.message,
videoPath
});
}
let generatedThumbnailKey = null;
try {
await generateVideoThumbnail(videoPath, thumbnailKey, options);
const storage = getStorage();
if (await storage.exists(thumbnailKey)) {
generatedThumbnailKey = thumbnailKey;
}
} catch (error) {
logger.error('Video thumbnail generation failed — continuing without a thumbnail', {
error: error.message,
videoPath
});
}
// Never return "success" with no thumbnail at all: the gallery grid
// (GridGalleryLayout/JustifiedGalleryLayout) falls back to
// `photo.thumbnail_url || photo.url` when there's no thumbnail, which
// makes AuthenticatedImage download the full ORIGINAL VIDEO and try to
// render it as an <img> — a broken tile and a multi-GB fetch just from
// opening the gallery (codex review, #1371/#1372). Fall 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, not to "no thumbnail". thumbnailKey is always
// `thumbnails/thumb_<name>.jpg` (see callers) — strip the prefix back to
// a filename so generateVideoPlaceholder recomputes this exact same key.
if (!generatedThumbnailKey) {
try {
const {
generateVideoPlaceholder,
DEFAULT_THUMBNAIL_WIDTH,
DEFAULT_THUMBNAIL_HEIGHT
} = require('./imageProcessor');
const placeholderFilename = path.basename(thumbnailKey).replace(/^thumb_/, '');
// Explicit width/height make generateVideoPlaceholder skip its
// configured-thumbnail-size DB lookup (see its own comment) — this
// call can run from inside processUploadedPhotos' open per-file
// SQLite transaction, where that lookup would otherwise deadlock.
const placeholderKey = await generateVideoPlaceholder(placeholderFilename, {
width: DEFAULT_THUMBNAIL_WIDTH,
height: DEFAULT_THUMBNAIL_HEIGHT
});
if (placeholderKey) {
generatedThumbnailKey = placeholderKey;
}
} catch (error) {
logger.error('Video placeholder generation also failed', { error: error.message, videoPath });
}
}
// A real thumbnail AND the ffmpeg-free SVG placeholder both failing points
// at something systemic (storage backend down, disk full) rather than a
// quirk of this one file — that's worth surfacing as a retryable failure
// rather than silently completing with no thumbnail at all, which would
// make the gallery fall back to rendering the raw video as an <img>
// (codex review, #1371/#1372). Metadata (if any was extracted) is lost
// here, same trade-off the callers' own pre-existing total-failure
// handling already makes.
if (!generatedThumbnailKey) {
throw new Error('Unable to generate any thumbnail (real or placeholder) for this video');
}
return {
success: true,
metadata,
thumbnailKey: generatedThumbnailKey
};
}
/**
+2 -1
View File
@@ -1,4 +1,5 @@
const { body, param, validationResult } = require('express-validator');
const { safeValidationErrors } = require('./routeHelpers');
const validator = require('validator');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('./emailNormalization');
@@ -225,7 +226,7 @@ const checkValidation = (req, res, next) => {
if (!errors.isEmpty()) {
return res.status(400).json({
error: 'Validation failed',
errors: errors.array()
errors: safeValidationErrors(errors)
});
}
next();
+63 -21
View File
@@ -7,6 +7,21 @@ const zxcvbn = require('zxcvbn');
const logger = require('./logger');
// Configuration
// zxcvbn's matching is superlinear in the input length and runs synchronously
// on the event loop, so an unbounded password is a denial-of-service primitive
// rather than a slow request. The reachable caller is
// POST /api/auth/password-strength, which is unauthenticated and sits behind
// express.json({ limit: '50mb' }) -- one request stops the whole process.
//
// Measured on this codebase (ms of blocked event loop per call):
// 64 -> 12 128 -> 41 192 -> 105 256 -> 218
// 384 -> 632 512 -> 1367 1000 -> 5097 5000 -> did not return in 2 min
//
// 128 keeps the worst case at the cost of an ordinary request while staying
// far above any real password: bcrypt consumes only the first 72 bytes, so
// anything longer already adds no entropy to the stored hash.
const MAX_PASSWORD_LENGTH = 128;
const PASSWORD_CONFIG = {
minLength: 8, // Reduced from 12 to 8 for better usability
requireUppercase: true,
@@ -34,6 +49,18 @@ const COMMON_PASSWORDS = [
function validatePassword(password, options = {}) {
const config = { ...PASSWORD_CONFIG, ...options };
const errors = [];
// Bail before any superlinear work touches the string. This is the guard for
// every caller, including ones added later -- the per-route length validator
// is defence in depth, not the control.
if (typeof password === 'string' && password.length > MAX_PASSWORD_LENGTH) {
return {
valid: false,
errors: [`Password must be at most ${MAX_PASSWORD_LENGTH} characters`],
score: 0,
feedback: {},
};
}
// Check if password exists
if (!password || typeof password !== 'string') {
@@ -94,11 +121,14 @@ function validatePassword(password, options = {}) {
// Check minimum strength score
if (strength.score < config.minStrengthScore) {
errors.push('Password is too weak. Please choose a stronger password');
}
// Add zxcvbn suggestions
if (strength.feedback.suggestions.length > 0) {
errors.push(...strength.feedback.suggestions);
// Surface zxcvbn's suggestions only alongside a real failure — they are
// advice, not requirements. A password that meets the configured policy
// must not be rejected just because zxcvbn has ideas for improving it
// (e.g. "Natasha2023" scores exactly minStrengthScore but always carries
// an "add another word" suggestion, which used to fail it).
if (strength.feedback.suggestions.length > 0) {
errors.push(...strength.feedback.suggestions);
}
}
return {
@@ -319,24 +349,35 @@ function generateSecurePassword(options = {}) {
if (charset.length === 0) {
throw new Error('At least one character type must be included');
}
// Generate password
// A requested length the validator will always reject makes the retry below
// unwinnable, so say so instead of spinning. MAX_PASSWORD_LENGTH is the cap
// validatePassword() applies; anything above it fails every candidate.
if (config.length > MAX_PASSWORD_LENGTH) {
throw new Error(`length must be at most ${MAX_PASSWORD_LENGTH}`);
}
const crypto = require('crypto');
let password = '';
for (let i = 0; i < config.length; i++) {
const randomIndex = crypto.randomInt(charset.length);
password += charset[randomIndex];
// Bounded retry rather than unbounded recursion. Every candidate failing is
// possible for reasons other than bad luck -- a charset that cannot satisfy
// the configured policy (numbers excluded while requireNumbers is on, say)
// -- and the previous `return generateSecurePassword(options)` turned that
// into a stack overflow rather than an error anyone could act on.
const MAX_ATTEMPTS = 100;
for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt += 1) {
let password = '';
for (let i = 0; i < config.length; i++) {
const randomIndex = crypto.randomInt(charset.length);
password += charset[randomIndex];
}
if (validatePassword(password).valid) return password;
}
// Ensure password meets requirements
const validation = validatePassword(password);
if (!validation.valid) {
// Recursively generate until we get a valid password
return generateSecurePassword(options);
}
return password;
throw new Error(
'Could not generate a password satisfying the configured policy — '
+ 'check that the selected character types can meet it',
);
}
/**
@@ -363,6 +404,7 @@ function logPasswordValidationFailure(context, errors, metadata = {}) {
}
module.exports = {
MAX_PASSWORD_LENGTH,
validatePassword,
validatePasswordInContext,
generateSecurePassword,
+49
View File
@@ -0,0 +1,49 @@
/**
* Content-Type for a served photo row. Invariant: the header is ALWAYS
* image/* or video/*, never the stored value verbatim.
*
* photos.mime_type is client-influenced: the chunked-upload path used to
* store whatever MIME the browser (or a crafted request) declared, and the
* S3 auto-importer stores whatever mime-types derives. Echoing it inline
* under the app origin turned a JPEG/HTML polyglot with mime_type text/html
* into stored HTML injection for every gallery guest. The admin photo route
* (#908 + external review) already resolved this properly; this is that
* logic, shared so every serving route applies the same rule.
*
* - Images ignore the stored value unless it is a header-safe raster type:
* migration 039 backfilled image/jpeg onto every legacy row (PNGs
* included), so the extension is the more trustworthy signal, normalised
* via the shared map, jpeg fallback when unknown. The scriptable svg /
* *+xml family is never honoured.
* - Videos prefer a stored video/ type, then the extension map (.mov ->
* video/quicktime, .webm -> video/webm, ...), then video/mp4.
* - Full-token validation, not a prefix check: header-invalid characters
* (video/mp4\r\nX: y) would make setHeader throw -- a permanent 500 for
* that photo instead of a safe fallback.
*/
const path = require('path');
const { EXTENSION_TO_MIME } = require('../services/uploadSettings');
function resolvePhotoContentType(photo) {
const ext = path.extname(photo?.filename || '').slice(1).toLowerCase();
// Own-property lookup: a client-controlled filename ending in .constructor
// / .__proto__ would otherwise return an inherited Object.prototype member.
const extMime = Object.prototype.hasOwnProperty.call(EXTENSION_TO_MIME, ext)
? EXTENSION_TO_MIME[ext]
: null;
const stored = typeof photo?.mime_type === 'string' ? photo.mime_type : '';
const storedVideoMime = /^video\/[\w.+-]+$/.test(stored) ? stored : null;
const storedImageMime =
/^image\/[\w.+-]+$/.test(stored) && !/^image\/svg|xml/i.test(stored)
? stored
: null;
const isVideo = photo?.media_type === 'video' ||
Boolean(storedVideoMime) ||
Boolean(extMime && extMime.startsWith('video/'));
return isVideo
? storedVideoMime || (extMime && extMime.startsWith('video/') ? extMime : null) || 'video/mp4'
: (extMime && extMime.startsWith('image/') ? extMime : null) || storedImageMime || 'image/jpeg';
}
module.exports = { resolvePhotoContentType };
+40
View File
@@ -0,0 +1,40 @@
/**
* Origin allow-listing shared by the CORS options and the multipart CSRF gate
* in server.js. Kept apart from server.js so it can be unit-tested without
* booting the app.
*/
function isAllowedOrigin(origin) {
const allowedOrigins = [
process.env.FRONTEND_URL || 'http://localhost:3005',
process.env.ADMIN_URL || 'http://localhost:3005'
];
if (process.env.NODE_ENV === 'development') {
allowedOrigins.push(
'http://localhost:5173', // Vite dev server
'http://localhost:3002', // Backend server
'http://localhost:3001', // For API testing
'http://localhost:3000' // Direct backend access
);
}
return allowedOrigins.indexOf(origin) !== -1;
}
// Origin check for multipart bodies (see the Content-Type gate below).
// Same-origin installs proxy /api through nginx and may not have FRONTEND_URL
// set, so an Origin matching the request Host is accepted alongside the CORS
// allowlist; Sec-Fetch-Site is authoritative when a browser sends it.
function multipartOriginAllowed(req) {
const site = req.headers['sec-fetch-site'];
if (site) return site !== 'cross-site';
const origin = req.headers.origin;
if (!origin) return true;
if (isAllowedOrigin(origin)) return true;
try {
return new URL(origin).host === req.headers.host;
} catch {
return false;
}
}
module.exports = { isAllowedOrigin, multipartOriginAllowed };
+10
View File
@@ -42,6 +42,15 @@ const handleAsync = (fn) => {
* // ... rest of handler
* }));
*/
/**
* express-validator's errors.array() carries `value` -- the submitted input.
* Returning it verbatim reflects whatever the caller sent (a rejected
* password, a 2mb string) back in the 400 body. Everything except `value` is
* kept, so consumers that read `msg` / `path` see no change.
*/
const safeValidationErrors = (errors) => errors.array().map(({ value, ...rest }) => rest);
const validateRequest = (req) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
@@ -174,6 +183,7 @@ const paginatedResponse = (data, total, page, limit) => {
module.exports = {
handleAsync,
validateRequest,
safeValidationErrors,
successResponse,
errorResponse,
withValidation,
+42
View File
@@ -171,8 +171,50 @@ function assertZipEntriesWithin(entries, extractRoot) {
}
}
/**
* Resolve a stored `/uploads/<kind>/<file>` URL to the file it names inside
* that upload directory, or null when the value is not one of ours.
*
* Only the basename is trusted: the URL comes from an admin-writable
* setting, and `path.join(storage, url)` after a `startsWith('/uploads/…')`
* check still collapses `..` segments, so it could name any file the process
* can delete. Restricting to a flat leaf inside the fixed directory is the
* whole control -- the upload routes only ever write flat filenames there.
*
* @param {string} url stored value, e.g. "/uploads/logos/logo-1.png"
* @param {string} kind "logos" | "favicons"
* @param {string} storageRoot the root the writer used (callers differ)
*/
function uploadedAssetPath(url, kind, storageRoot) {
if (!url || typeof url !== 'string') return null;
const prefix = `/uploads/${kind}/`;
if (!url.startsWith(prefix)) return null;
const leaf = url.slice(prefix.length);
if (!leaf || leaf === '.' || leaf === '..' || path.basename(leaf) !== leaf) return null;
return path.join(storageRoot, 'uploads', kind, leaf);
}
/**
* Resolve business_profile.logo_path to the file the PDF-logo upload route
* wrote, or null. logo_path is a free-text field on the profile PUT (an
* admin may point it at a file managed elsewhere), so it must never be
* unlinked as given: a `/pdf-logo-\d+\./` marker test plus path.join let
* `pdf-logo-1./../../../<anything>` -- or any absolute path containing the
* marker -- delete arbitrary files. Only a flat `pdf-logo-<n>.<ext>` leaf
* inside uploads/logos is ever named.
*/
function uploadedPdfLogoPath(logoPath, storageRoot) {
if (!logoPath || typeof logoPath !== 'string') return null;
const normalized = logoPath.replace(/^\/+/, '');
const match = /^uploads\/logos\/(pdf-logo-\d+\.[A-Za-z0-9]+)$/.exec(normalized);
if (!match) return null;
return path.join(storageRoot, 'uploads', 'logos', match[1]);
}
module.exports = {
assertPathInside,
assertContractPdfPath,
assertZipEntriesWithin,
uploadedAssetPath,
uploadedPdfLogoPath,
};
+5
View File
@@ -55,6 +55,11 @@ function pipeStreamToResponse(stream, res, options = {}) {
res.removeHeader('ETag');
res.removeHeader('Content-Type');
res.removeHeader('Content-Disposition');
// Range headers describe the body that is no longer coming. Left behind,
// a 500 goes out still advertising `Content-Range: bytes 0-9/40`, which
// tells a resuming client the error response IS the partial content.
res.removeHeader('Content-Range');
res.removeHeader('Accept-Ranges');
res.setHeader('Cache-Control', 'no-store');
if (gone) {
+16 -7
View File
@@ -3,6 +3,7 @@
* Provides ability to invalidate tokens before expiration
*/
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const logger = require('./logger');
@@ -28,15 +29,23 @@ function buildTokenId(payload) {
async function revokeToken(token, reason, metadata = {}) {
try {
// Extract token info without full verification (it might be compromised)
const parts = token.split('.');
if (parts.length !== 3) {
throw new Error('Invalid token format');
// The signature MUST be verified before anything is written. The
// revocation key is `${id}-${iat}-${type}` (buildTokenId), and the
// logout endpoints are unauthenticated, so a raw base64 decode let
// anyone forge a three-part string naming another user's id, type and
// login second and insert a row that isTokenRevoked() then matched for
// that user's real session -- a remote forced logout of any admin,
// customer or gallery session, plus never-swept rows when `exp` was set
// far in the future. Expiry is ignored on purpose: revoking an already
// expired token is harmless and keeps logout idempotent.
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256'],
ignoreExpiration: true,
});
if (!payload || typeof payload !== 'object') {
throw new Error('Invalid token payload');
}
// Decode payload
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
// user_id is integer-typed in revoked_tokens; for non-admin tokens
// we may not have an integer (customer) or any id at all (gallery
// tokens use eventId). Coerce to null instead of letting an
+242 -198
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "3.74.0-beta.0",
"version": "3.46.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "3.74.0-beta.0",
"version": "3.46.8",
"dependencies": {
"@dagrejs/dagre": "^3.0.0",
"@fullcalendar/core": "^6.1.20",
@@ -1226,29 +1226,43 @@
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/types": "^0.15.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/node": {
"version": "0.16.7",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
"version": "0.16.8",
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@humanfs/core": "^0.19.1",
"@humanfs/core": "^0.19.2",
"@humanfs/types": "^0.15.0",
"@humanwhocodes/retry": "^0.4.0"
},
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanfs/types": {
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18.0"
}
},
"node_modules/@humanwhocodes/module-importer": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
@@ -2183,9 +2197,9 @@
"license": "MIT"
},
"node_modules/@remix-run/router": {
"version": "1.23.3",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.3.tgz",
"integrity": "sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==",
"version": "1.23.4",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz",
"integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
@@ -2956,9 +2970,9 @@
}
},
"node_modules/@tiptap/core": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.1.tgz",
"integrity": "sha512-nkerkl8syHj44ZzAB7oA2GPmmZINKBKCa79FuNvmGJrJ4qyZwlkDzszud23YteFZEytbc87kVd/fP76ROS6sLg==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.27.2.tgz",
"integrity": "sha512-ABL1N6eoxzDzC1bYvkMbvyexHacszsKdVPYqhl5GwHLOvpZcv9VE9QaKwDILTyz5voCA0lGcAAXZp+qnXOk5lQ==",
"license": "MIT",
"peer": true,
"funding": {
@@ -2970,9 +2984,9 @@
}
},
"node_modules/@tiptap/extension-blockquote": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.1.tgz",
"integrity": "sha512-QrUX3muElDrNjKM3nqCSAtm3H3pT33c6ON8kwRiQboOAjT/9D57Cs7XEVY7r6rMaJPeKztrRUrNVF9w/w/6B0A==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.27.2.tgz",
"integrity": "sha512-oIGZgiAeA4tG3YxbTDfrmENL4/CIwGuP3THtHsNhwRqwsl9SfMk58Ucopi2GXTQSdYXpRJ0ahE6nPqB5D6j/Zw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2983,9 +2997,9 @@
}
},
"node_modules/@tiptap/extension-bold": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.1.tgz",
"integrity": "sha512-g4l4p892x/r7mhea8syp3fNYODxsDrimgouQ+q4DKXIgQmm5+uNhyuEPexP3I8TFNXqQ4DlMNFoM9yCqk97etQ==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.27.2.tgz",
"integrity": "sha512-bR7J5IwjCGQ0s3CIxyMvOCnMFMzIvsc5OVZKscTN5UkXzFsaY6muUAIqtKxayBUucjtUskm5qZowJITCeCb1/A==",
"license": "MIT",
"funding": {
"type": "github",
@@ -2996,9 +3010,9 @@
}
},
"node_modules/@tiptap/extension-bubble-menu": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.1.tgz",
"integrity": "sha512-ki1R27VsSvY2tT9Q2DIlcATwLOoEjf5DsN+5sExarQ8S/ZxT/tvIjRxB8Dx7lb2a818W5f/NER26YchGtmHfpg==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.27.2.tgz",
"integrity": "sha512-VkwlCOcr0abTBGzjPXklJ92FCowG7InU8+Od9FyApdLNmn0utRYGRhw0Zno6VgE9EYr1JY4BRnuSa5f9wlR72w==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
@@ -3013,9 +3027,9 @@
}
},
"node_modules/@tiptap/extension-bullet-list": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.1.tgz",
"integrity": "sha512-5FmnfXkJ76wN4EbJNzBhAlmQxho8yEMIJLchTGmXdsD/n/tsyVVtewnQYaIOj/Z7naaGySTGDmjVtLgTuQ+Sxw==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.27.2.tgz",
"integrity": "sha512-gmFuKi97u5f8uFc/GQs+zmezjiulZmFiDYTh3trVoLRoc2SAHOjGEB7qxdx7dsqmMN7gwiAWAEVurLKIi1lnnw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3026,9 +3040,9 @@
}
},
"node_modules/@tiptap/extension-character-count": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.27.1.tgz",
"integrity": "sha512-PCkPW7lOiIirM7QlzgumRaTQWbkVV+3NZ6e2k+8QnDNDAhT+kIsrXpzka7Uq3mfpJyHbbj1+oNvPhS/VIavQbA==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.27.2.tgz",
"integrity": "sha512-EcQRIvbLbMDDzo7uFqXYgh1CfgedS9sYX4BllktY2OlXLPdNpwo9t8WMK/a7soESNv0Le3WZ5pNvnNhv7Z2YdA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3040,9 +3054,9 @@
}
},
"node_modules/@tiptap/extension-code": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.1.tgz",
"integrity": "sha512-i65wUGJevzBTIIUBHBc1ggVa27bgemvGl/tY1/89fEuS/0Xmre+OQjw8rCtSLevoHSiYYLgLRlvjtUSUhE4kgg==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.27.2.tgz",
"integrity": "sha512-7X9AgwqiIGXoZX7uvdHQsGsjILnN/JaEVtqfXZnPECzKGaWHeK/Ao4sYvIIIffsyZJA8k5DC7ny2/0sAgr2TuA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3053,9 +3067,9 @@
}
},
"node_modules/@tiptap/extension-code-block": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.1.tgz",
"integrity": "sha512-wCI5VIOfSAdkenCWFvh4m8FFCJ51EOK+CUmOC/PWUjyo2Dgn8QC8HMi015q8XF7886T0KvYVVoqxmxJSUDAYNg==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.27.2.tgz",
"integrity": "sha512-KgvdQHS4jXr79aU3wZOGBIZYYl9vCB7uDEuRFV4so2rYrfmiYMw3T8bTnlNEEGe4RUeAms1i4fdwwvQp9nR1Dw==",
"license": "MIT",
"peer": true,
"funding": {
@@ -3068,9 +3082,9 @@
}
},
"node_modules/@tiptap/extension-code-block-lowlight": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.27.1.tgz",
"integrity": "sha512-Ijg9724uX/l4LXLELEeztZIgg+bDE/jJCkgS1+mavkRA/qtidpQkHo7L/Ry22fmj/ktCtZLjPXE5JAPAoRU6zA==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.27.2.tgz",
"integrity": "sha512-v6NKStBbQ/XCc1NnCi3ObsL1DsxadSIBtUQNA/B+urkPgn5LEy72HAGlf0xwjRaNkAGSaTASLKmc84L5q5zlGQ==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3085,9 +3099,9 @@
}
},
"node_modules/@tiptap/extension-document": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.1.tgz",
"integrity": "sha512-NtJzJY7Q/6XWjpOm5OXKrnEaofrcc1XOTYlo/SaTwl8k2bZo918Vl0IDBWhPVDsUN7kx767uHwbtuQZ+9I82hA==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.27.2.tgz",
"integrity": "sha512-CFhAYsPnyYnosDC4639sCJnBUnYH4Cat9qH5NZWHVvdgtDwu8GZgZn2eSzaKSYXWH1vJ9DSlCK+7UyC3SNXIBA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3098,9 +3112,9 @@
}
},
"node_modules/@tiptap/extension-dropcursor": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.1.tgz",
"integrity": "sha512-3MBQRGHHZ0by3OT0CWbLKS7J3PH9PpobrXjmIR7kr0nde7+bHqxXiVNuuIf501oKU9rnEUSedipSHkLYGkmfsA==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.27.2.tgz",
"integrity": "sha512-oEu/OrktNoQXq1x29NnH/GOIzQZm8ieTQl3FK27nxfBPA89cNoH4mFEUmBL5/OFIENIjiYG3qWpg6voIqzswNw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3112,9 +3126,9 @@
}
},
"node_modules/@tiptap/extension-floating-menu": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.1.tgz",
"integrity": "sha512-nUk/8DbiXO69l6FDwkWso94BTf52IBoWALo+YGWT6o+FO6cI9LbUGghEX2CdmQYXCvSvwvISF2jXeLQWNZvPZQ==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.27.2.tgz",
"integrity": "sha512-GUN6gPIGXS7ngRJOwdSmtBRBDt9Kt9CM/9pSwKebhLJ+honFoNA+Y6IpVyDvvDMdVNgBchiJLs6qA5H97gAePQ==",
"license": "MIT",
"dependencies": {
"tippy.js": "^6.3.7"
@@ -3129,9 +3143,9 @@
}
},
"node_modules/@tiptap/extension-gapcursor": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.1.tgz",
"integrity": "sha512-A9e1jr+jGhDWzNSXtIO6PYVYhf5j/udjbZwMja+wCE/3KvZU9V3IrnGKz1xNW+2Q2BDOe1QO7j5uVL9ElR6nTA==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.27.2.tgz",
"integrity": "sha512-/c9VF1HBxj+AP54XGVgCmD9bEGYc5w5OofYCFQgM7l7PB1J00A4vOke0oPkHJnqnOOyPlFaxO/7N6l3XwFcnKA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3143,9 +3157,9 @@
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.1.tgz",
"integrity": "sha512-W4hHa4Io6QCTwpyTlN6UAvqMIQ7t56kIUByZhyY9EWrg/+JpbfpxE1kXFLPB4ZGgwBknFOw+e4bJ1j3oAbTJFw==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.27.2.tgz",
"integrity": "sha512-kSRVGKlCYK6AGR0h8xRkk0WOFGXHIIndod3GKgWU49APuIGDiXd8sziXsSlniUsWmqgDmDXcNnSzPcV7AQ8YNg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3156,9 +3170,9 @@
}
},
"node_modules/@tiptap/extension-heading": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.1.tgz",
"integrity": "sha512-6xoC7igZlW1EmnQ5WVH9IL7P1nCQb3bBUaIDLvk7LbweEogcTUECI4Xg1vxMOVmj9tlDe1I4BsgfcKpB5KEsZw==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.27.2.tgz",
"integrity": "sha512-iM3yeRWuuQR/IRQ1djwNooJGfn9Jts9zF43qZIUf+U2NY8IlvdNsk2wTOdBgh6E0CamrStPxYGuln3ZS4fuglw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3169,9 +3183,9 @@
}
},
"node_modules/@tiptap/extension-history": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.1.tgz",
"integrity": "sha512-K8PHC9gegSAt0wzSlsd4aUpoEyIJYOmVVeyniHr1P1mIblW1KYEDbRGbDlrLALTyUEfMcBhdIm8zrB9X2Nihvg==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.27.2.tgz",
"integrity": "sha512-+hSyqERoFNTWPiZx4/FCyZ/0eFqB9fuMdTB4AC/q9iwu3RNWAQtlsJg5230bf/qmyO6bZxRUc0k8p4hrV6ybAw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3183,9 +3197,9 @@
}
},
"node_modules/@tiptap/extension-horizontal-rule": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.1.tgz",
"integrity": "sha512-WxXWGEEsqDmGIF2o9av+3r9Qje4CKrqrpeQY6aRO5bxvWX9AabQCfasepayBok6uwtvNzh3Xpsn9zbbSk09dNA==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.27.2.tgz",
"integrity": "sha512-WGWUSgX+jCsbtf9Y9OCUUgRZYuwjVoieW5n6mAUohJ9/6gc6sGIOrUpBShf+HHo6WD+gtQjRd+PssmX3NPWMpg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3197,9 +3211,9 @@
}
},
"node_modules/@tiptap/extension-italic": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.1.tgz",
"integrity": "sha512-rcm0GyniWW0UhcNI9+1eIK64GqWQLyIIrWGINslvqSUoBc+WkfocLvv4CMpRkzKlfsAxwVIBuH2eLxHKDtAREA==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.27.2.tgz",
"integrity": "sha512-1OFsw2SZqfaqx5Fa5v90iNlPRcqyt+lVSjBwTDzuPxTPFY4Q0mL89mKgkq2gVHYNCiaRkXvFLDxaSvBWbmthgg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3210,9 +3224,9 @@
}
},
"node_modules/@tiptap/extension-link": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.1.tgz",
"integrity": "sha512-cCwWPZsnVh9MXnGOqSIRXPPuUixRDK8eMN2TvqwbxUBb1TU7b/HtNvfMU4tAOqAuMRJ0aJkFuf3eB0Gi8LVb1g==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.27.2.tgz",
"integrity": "sha512-bnP61qkr0Kj9Cgnop1hxn2zbOCBzNtmawxr92bVTOE31fJv6FhtCnQiD6tuPQVGMYhcmAj7eihtvuEMFfqEPcQ==",
"license": "MIT",
"dependencies": {
"linkifyjs": "^4.3.2"
@@ -3227,9 +3241,9 @@
}
},
"node_modules/@tiptap/extension-list-item": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.1.tgz",
"integrity": "sha512-dtsxvtzxfwOJP6dKGf0vb2MJAoDF2NxoiWzpq0XTvo7NGGYUHfuHjX07Zp0dYqb4seaDXjwsi5BIQUOp3+WMFQ==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.27.2.tgz",
"integrity": "sha512-eJNee7IEGXMnmygM5SdMGDC8m/lMWmwNGf9fPCK6xk0NxuQRgmZHL6uApKcdH6gyNcRPHCqvTTkhEP7pbny/fg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3240,9 +3254,9 @@
}
},
"node_modules/@tiptap/extension-ordered-list": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.1.tgz",
"integrity": "sha512-U1/sWxc2TciozQsZjH35temyidYUjvroHj3PUPzPyh19w2fwKh1NSbFybWuoYs6jS3XnMSwnM2vF52tOwvfEmA==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.27.2.tgz",
"integrity": "sha512-M7A4tLGJcLPYdLC4CI2Gwl8LOrENQW59u3cMVa+KkwG1hzSJyPsbDpa1DI6oXPC2WtYiTf22zrbq3gVvH+KA2w==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3253,9 +3267,9 @@
}
},
"node_modules/@tiptap/extension-paragraph": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.1.tgz",
"integrity": "sha512-R3QdrHcUdFAsdsn2UAIvhY0yWyHjqGyP/Rv8RRdN0OyFiTKtwTPqreKMHKJOflgX4sMJl/OpHTpNG1Kaf7Lo2A==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.27.2.tgz",
"integrity": "sha512-elYVn2wHJJ+zB9LESENWOAfI4TNT0jqEN34sMA/hCtA4im1ZG2DdLHwkHIshj/c4H0dzQhmsS/YmNC5Vbqab/A==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3266,9 +3280,9 @@
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.27.1.tgz",
"integrity": "sha512-UbXaibHHFE+lOTlw/vs3jPzBoj1sAfbXuTAhXChjgYIcTTY5Cr6yxwcymLcimbQ79gf04Xkua2FCN3YsJxIFmw==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.27.2.tgz",
"integrity": "sha512-IjsgSVYJRjpAKmIoapU0E2R4E2FPY3kpvU7/1i7PUYisylqejSJxmtJPGYw0FOMQY9oxnEEvfZHMBA610tqKpg==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3280,9 +3294,9 @@
}
},
"node_modules/@tiptap/extension-strike": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.1.tgz",
"integrity": "sha512-S9I//K8KPgfFTC5I5lorClzXk0g4lrAv9y5qHzHO5EOWt7AFl0YTg2oN8NKSIBK4bHRnPIrjJJKv+dDFnUp5jQ==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.27.2.tgz",
"integrity": "sha512-HHIjhafLhS2lHgfAsCwC1okqMsQzR4/mkGDm4M583Yftyjri1TNA7lzhzXWRFWiiMfJxKtdjHjUAQaHuteRTZw==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3293,9 +3307,9 @@
}
},
"node_modules/@tiptap/extension-text": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.1.tgz",
"integrity": "sha512-a4GCT+GZ9tUwl82F4CEum9/+WsuW0/De9Be/NqrMmi7eNfAwbUTbLCTFU0gEvv25WMHCoUzaeNk/qGmzeVPJ1Q==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.27.2.tgz",
"integrity": "sha512-Xk7nYcigljAY0GO9hAQpZ65ZCxqOqaAlTPDFcKerXmlkQZP/8ndx95OgUb1Xf63kmPOh3xypurGS2is3v0MXSA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3306,9 +3320,9 @@
}
},
"node_modules/@tiptap/extension-text-align": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.1.tgz",
"integrity": "sha512-D7dLPk7y5mDn9ZNANQ4K2gCq4vy+Emm5AdeWOGzNeqJsYrBotiQYXd9rb1QYjdup2kzAoKduMTUXV92ujo5cEg==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.27.2.tgz",
"integrity": "sha512-0Pyks6Hu+Q/+9+5/osoSv0SP6jIerdWMYbi13aaZLsJoj3lBj5WNaE11JtAwSFN5sx0IbqhDSlp1zkvRnzgZ8g==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3319,9 +3333,9 @@
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.1.tgz",
"integrity": "sha512-NagQ9qLk0Ril83gfrk+C65SvTqPjL3WVnLF2arsEVnCrxcx3uDOvdJW67f/K5HEwEHsoqJ4Zq9Irco/koXrOXA==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.27.2.tgz",
"integrity": "sha512-Omk+uxjJLyEY69KStpCw5fA9asvV+MGcAX2HOxyISDFoLaL49TMrNjhGAuz09P1L1b0KGXo4ml7Q3v/Lfy4WPA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -3332,9 +3346,9 @@
}
},
"node_modules/@tiptap/pm": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.1.tgz",
"integrity": "sha512-ijKo3+kIjALthYsnBmkRXAuw2Tswd9gd7BUR5OMfIcjGp8v576vKxOxrRfuYiUM78GPt//P0sVc1WV82H5N0PQ==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.27.2.tgz",
"integrity": "sha512-kaEg7BfiJPDQMKbjVIzEPO3wlcA+pZb2tlcK9gPrdDnEFaec2QTF1sXz2ak2IIb2curvnIrQ4yrfHgLlVA72wA==",
"license": "MIT",
"peer": true,
"dependencies": {
@@ -3363,13 +3377,13 @@
}
},
"node_modules/@tiptap/react": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.1.tgz",
"integrity": "sha512-leJximSjYJuhLJQv9azOP9R7w6zuxVgKOHYT4w83Gte7GhWMpNL6xRWzld280vyq/YW/cSYjPb/8ESEOgKNBdQ==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.27.2.tgz",
"integrity": "sha512-0EAs8Cpkfbvben1PZ34JN2Nd79Dhioynm2jML27DBbf1VWPk+FFWFGTMLUT0bu+Np5iVxio8fqV9t0mc4D6thA==",
"license": "MIT",
"dependencies": {
"@tiptap/extension-bubble-menu": "^2.27.1",
"@tiptap/extension-floating-menu": "^2.27.1",
"@tiptap/extension-bubble-menu": "^2.27.2",
"@tiptap/extension-floating-menu": "^2.27.2",
"@types/use-sync-external-store": "^0.0.6",
"fast-deep-equal": "^3",
"use-sync-external-store": "^1"
@@ -3386,32 +3400,32 @@
}
},
"node_modules/@tiptap/starter-kit": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.1.tgz",
"integrity": "sha512-uQQlP0Nmn9eq19qm8YoOeloEfmcGbPpB1cujq54Q6nPgxaBozR7rE7tXbFTinxRW2+Hr7XyNWhpjB7DMNkdU2Q==",
"version": "2.27.2",
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.27.2.tgz",
"integrity": "sha512-bb0gJvPoDuyRUQ/iuN52j1//EtWWttw+RXAv1uJxfR0uKf8X7uAqzaOOgwjknoCIDC97+1YHwpGdnRjpDkOBxw==",
"license": "MIT",
"dependencies": {
"@tiptap/core": "^2.27.1",
"@tiptap/extension-blockquote": "^2.27.1",
"@tiptap/extension-bold": "^2.27.1",
"@tiptap/extension-bullet-list": "^2.27.1",
"@tiptap/extension-code": "^2.27.1",
"@tiptap/extension-code-block": "^2.27.1",
"@tiptap/extension-document": "^2.27.1",
"@tiptap/extension-dropcursor": "^2.27.1",
"@tiptap/extension-gapcursor": "^2.27.1",
"@tiptap/extension-hard-break": "^2.27.1",
"@tiptap/extension-heading": "^2.27.1",
"@tiptap/extension-history": "^2.27.1",
"@tiptap/extension-horizontal-rule": "^2.27.1",
"@tiptap/extension-italic": "^2.27.1",
"@tiptap/extension-list-item": "^2.27.1",
"@tiptap/extension-ordered-list": "^2.27.1",
"@tiptap/extension-paragraph": "^2.27.1",
"@tiptap/extension-strike": "^2.27.1",
"@tiptap/extension-text": "^2.27.1",
"@tiptap/extension-text-style": "^2.27.1",
"@tiptap/pm": "^2.27.1"
"@tiptap/core": "^2.27.2",
"@tiptap/extension-blockquote": "^2.27.2",
"@tiptap/extension-bold": "^2.27.2",
"@tiptap/extension-bullet-list": "^2.27.2",
"@tiptap/extension-code": "^2.27.2",
"@tiptap/extension-code-block": "^2.27.2",
"@tiptap/extension-document": "^2.27.2",
"@tiptap/extension-dropcursor": "^2.27.2",
"@tiptap/extension-gapcursor": "^2.27.2",
"@tiptap/extension-hard-break": "^2.27.2",
"@tiptap/extension-heading": "^2.27.2",
"@tiptap/extension-history": "^2.27.2",
"@tiptap/extension-horizontal-rule": "^2.27.2",
"@tiptap/extension-italic": "^2.27.2",
"@tiptap/extension-list-item": "^2.27.2",
"@tiptap/extension-ordered-list": "^2.27.2",
"@tiptap/extension-paragraph": "^2.27.2",
"@tiptap/extension-strike": "^2.27.2",
"@tiptap/extension-text": "^2.27.2",
"@tiptap/extension-text-style": "^2.27.2",
"@tiptap/pm": "^2.27.2"
},
"funding": {
"type": "github",
@@ -3854,9 +3868,9 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz",
"integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4300,16 +4314,42 @@
}
},
"node_modules/axios": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"version": "1.20.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz",
"integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"form-data": "^4.0.6",
"https-proxy-agent": "^5.0.1",
"proxy-from-env": "^2.1.0"
}
},
"node_modules/axios/node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axios/node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -4318,13 +4358,16 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.9.12",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.12.tgz",
"integrity": "sha512-Mij6Lij93pTAIsSYy5cyBQ975Qh9uLEc5rwGTpomiZeXZL9yIS6uORJakb3ScHgfs0serMMfIbXzokPMuEiRyw==",
"version": "2.11.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
"integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/binary-extensions": {
@@ -4341,9 +4384,9 @@
}
},
"node_modules/brace-expansion": {
"version": "1.1.14",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz",
"integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==",
"version": "1.1.18",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4365,9 +4408,9 @@
}
},
"node_modules/browserslist": {
"version": "4.28.1",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
"integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"dev": true,
"funding": [
{
@@ -4386,11 +4429,11 @@
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
"electron-to-chromium": "^1.5.263",
"node-releases": "^2.0.27",
"update-browserslist-db": "^1.2.0"
"baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001809",
"electron-to-chromium": "^1.5.402",
"node-releases": "^2.0.53",
"update-browserslist-db": "^1.3.0"
},
"bin": {
"browserslist": "cli.js"
@@ -4443,9 +4486,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001762",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001762.tgz",
"integrity": "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw==",
"version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
"integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"dev": true,
"funding": [
{
@@ -4928,7 +4971,6 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -5007,9 +5049,9 @@
"license": "MIT"
},
"node_modules/dompurify": {
"version": "3.4.11",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz",
"integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==",
"version": "3.4.14",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
"integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
@@ -5030,9 +5072,9 @@
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.267",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz",
"integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==",
"version": "1.5.420",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
"integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==",
"dev": true,
"license": "ISC"
},
@@ -5856,16 +5898,16 @@
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/glob/node_modules/minimatch": {
@@ -6137,16 +6179,16 @@
}
},
"node_modules/i18next-cli/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/i18next-cli/node_modules/chokidar": {
@@ -6588,9 +6630,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"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==",
"dev": true,
"funding": [
{
@@ -6793,9 +6835,9 @@
"license": "MIT"
},
"node_modules/linkify-it": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.1.tgz",
"integrity": "sha512-wVoTjP4Q6R0NW5hiZkVJaFZPWgtXfoGF+6LucL3/FtiNjmcHhYjEr5f1Kqjirc1nBW07J/ZuRFumqr2oqccEWg==",
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
"funding": [
{
"type": "github",
@@ -7144,7 +7186,6 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"license": "MIT"
},
"node_modules/mute-stream": {
@@ -7170,9 +7211,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true,
"funding": [
{
@@ -7216,11 +7257,14 @@
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
"version": "2.0.54",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
"integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
"dev": true,
"license": "MIT"
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
@@ -7577,9 +7621,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"version": "8.5.27",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.27.tgz",
"integrity": "sha512-79Iho8QeYyooJ8e9lCRyTVlyTAkS/kXBYKff6TMzS3kEWGQ8Ds5UEtXpGrSUDLUWok6QTvxeYy0GO8fopHnaSA==",
"dev": true,
"funding": [
{
@@ -7598,7 +7642,7 @@
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"nanoid": "^3.3.18",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -7720,9 +7764,9 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz",
"integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -8223,12 +8267,12 @@
}
},
"node_modules/react-router": {
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.4.tgz",
"integrity": "sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==",
"version": "6.30.6",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz",
"integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.3"
"@remix-run/router": "1.23.4"
},
"engines": {
"node": ">=14.0.0"
@@ -8238,13 +8282,13 @@
}
},
"node_modules/react-router-dom": {
"version": "6.30.4",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.4.tgz",
"integrity": "sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==",
"version": "6.30.6",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz",
"integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==",
"license": "MIT",
"dependencies": {
"@remix-run/router": "1.23.3",
"react-router": "6.30.4"
"@remix-run/router": "1.23.4",
"react-router": "6.30.6"
},
"engines": {
"node": ">=14.0.0"
@@ -9116,9 +9160,9 @@
}
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
"integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
"dev": true,
"funding": [
{
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.46.7",
"version": "3.46.11",
"type": "module",
"scripts": {
"dev": "vite",
@@ -5,7 +5,7 @@ import { toast } from 'react-toastify';
import { Button } from '../common';
import { api } from '../../config/api';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import { extensionsToMimeTypes, extensionsToAcceptString } from '../../utils/fileTypes';
import { extensionsToMimeTypes, buildUploadAcceptString } from '../../utils/fileTypes';
interface UserPhotoUploadProps {
eventId: number;
@@ -48,8 +48,10 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
[publicSettings?.allowed_file_types]
);
// #1117 — on Android this appends a type the photo picker can't handle, so
// the system falls back to the chooser that actually offers the camera.
const acceptString = useMemo(
() => extensionsToAcceptString(publicSettings?.allowed_file_types),
() => buildUploadAcceptString(publicSettings?.allowed_file_types),
[publicSettings?.allowed_file_types]
);
+1 -1
View File
@@ -1328,7 +1328,7 @@
"maxUploadBatchSize": "Max. Upload-Paketgröße (MB)",
"maxUploadBatchSizeHelp": "Maximale Größe pro Upload-Anfrage. Reduzieren Sie diesen Wert bei Nutzung eines Reverse-Proxys mit Größenbeschränkung (z.B. Cloudflare: 100MB).",
"allowedFileTypes": "Erlaubte Dateitypen",
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen, z. B. jpg,jpeg,png,webp. Gilt für alle Upload-Wege, auch Gast-Uploads und die Chunked-API für große Dateien. Videos sind standardmäßig aus: mp4, mov oder webm hinzufügen, um sie zuzulassen.",
"featureToggles": "Funktionsschalter",
"enableAnalytics": "Analytics-Tracking aktivieren",
"enableRegistration": "Selbstregistrierung für Admins erlauben",
+1 -1
View File
@@ -869,7 +869,7 @@
"maxUploadBatchSize": "Max Upload Batch Size (MB)",
"maxUploadBatchSizeHelp": "Maximum size per upload request. Lower this if behind a reverse proxy with request size limits (e.g. Cloudflare: 100MB).",
"allowedFileTypes": "Allowed File Types",
"allowedFileTypesHelp": "Comma-separated list of file extensions",
"allowedFileTypesHelp": "Comma-separated list of file extensions, e.g. jpg,jpeg,png,webp. Applies to every upload path, including guest uploads and the chunked (large-file) API. Videos are off by default: add mp4, mov or webm to accept them.",
"featureToggles": "Feature Toggles",
"enableAnalytics": "Enable analytics tracking",
"enableRegistration": "Allow self-registration for admins",
@@ -0,0 +1,44 @@
import { describe, it, expect } from 'vitest';
import { buildUploadAcceptString } from '../fileTypes';
describe('buildUploadAcceptString (#1117)', () => {
const ANDROID = 'Mozilla/5.0 (Linux; Android 16; Pixel 9) AppleWebKit/537.36 Chrome/151.0.0.0 Mobile Safari/537.36';
const IOS = 'Mozilla/5.0 (iPhone; CPU iPhone OS 26_6 like Mac OS X) AppleWebKit/605.1.15 Version/26.0 Mobile Safari/604.1';
const DESKTOP = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/151.0.0.0 Safari/537.36';
const FIREFOX_ANDROID = 'Mozilla/5.0 (Android 16; Mobile; rv:140.0) Gecko/140.0 Firefox/140.0';
it('appends the camera token on Android so the chooser offers the camera', () => {
expect(buildUploadAcceptString('jpg,png', ANDROID)).toBe('image/jpeg,image/png,android/allowCamera');
});
it('adds nothing a guest could actually select', () => {
// The token exists to flip Chrome out of the photo picker, not to widen
// the allowlist. An earlier revision used .pdf, which does flip it but
// also offers PDFs — pick one and you get "Invalid file type".
const accept = buildUploadAcceptString('jpg,png', ANDROID);
expect(accept).not.toMatch(/\.pdf|application\/pdf/);
expect(accept.split(',').filter((t) => t.startsWith('image/') || t.startsWith('video/')))
.toEqual(['image/jpeg', 'image/png']);
});
it('leaves Firefox for Android alone — its chooser already offers the camera', () => {
// The UA says Android, but the behaviour this works around is Chromium's.
expect(buildUploadAcceptString('jpg,png', FIREFOX_ANDROID)).toBe('image/jpeg,image/png');
});
it('leaves iOS and desktop untouched — their pickers already work', () => {
expect(buildUploadAcceptString('jpg,png', IOS)).toBe('image/jpeg,image/png');
expect(buildUploadAcceptString('jpg,png', DESKTOP)).toBe('image/jpeg,image/png');
});
it('keeps offering video when the admin configured it', () => {
// The workaround must not narrow the accept list to images: an install
// with video enabled still has to offer mp4/mov in the chooser.
expect(buildUploadAcceptString('jpg,mp4,mov', ANDROID)).toBe('image/jpeg,video/mp4,video/quicktime,android/allowCamera');
});
it('falls back to the configured default set, not a wider image/*', () => {
expect(buildUploadAcceptString('', DESKTOP)).toBe('image/jpeg,image/png,image/webp');
expect(buildUploadAcceptString('', ANDROID)).toBe('image/jpeg,image/png,image/webp,android/allowCamera');
});
});
+37
View File
@@ -46,3 +46,40 @@ export function extensionsToMimeTypes(extString?: string | null): string[] {
export function extensionsToAcceptString(extString?: string | null): string {
return extensionsToMimeTypes(extString).join(',');
}
/**
* `accept` for the guest upload input (#1117).
*
* Chrome and Edge on Android 14/15 route an `<input>` whose accept list is
* entirely image and video types to the system *photo picker*, which has no
* camera tile so a guest standing at the event can only pick a photo already
* in their gallery, never take one. Adding a value that picker cannot satisfy
* makes Chrome fall back to the general document chooser, which does offer the
* camera.
*
* `android/allowCamera` is the token the workaround converged on. It is not a
* real MIME type and matches no file, which is the point: it flips the picker
* without advertising anything extra as selectable. An earlier revision used
* `.pdf`, which works by the same mechanism but offers PDFs in the chooser
* pick one and you get "Invalid file type" for your trouble.
*
* Gated to Android MINUS Firefox. The behaviour is Chromium's Chrome and
* Edge on Android 14/15 and Firefox for Android, whose UA also says
* `Android`, opens a chooser that already offers the camera. Handing it a
* token invented to reroute a picker it does not use is at best inert and at
* worst changes a chooser that was working.
*
* UA sniffing is the wrong tool in general, but there is no feature query for
* "which picker will this open", and the failure mode of a wrong guess is an
* accept token the browser ignores.
*
* Neither token widens what is actually accepted: `addFiles` validates every
* file against `extensionsToMimeTypes`, which only ever emits types it has a
* mapping for, so nothing new can get past it.
*/
export function buildUploadAcceptString(extString?: string | null, userAgent?: string): string {
const accept = extensionsToAcceptString(extString);
const ua = userAgent ?? (typeof navigator !== 'undefined' ? navigator.userAgent : '');
const needsCameraToken = /Android/i.test(ua) && !/Firefox/i.test(ua);
return needsCameraToken ? `${accept},android/allowCamera` : accept;
}
+14 -7
View File
@@ -14,8 +14,8 @@ IFS=$'\n\t'
readonly SCRIPT_VERSION="2.1.0"
readonly APP_NAME="PicPeak"
readonly REPO_URL="https://github.com/PicPeak/picpeak.git"
readonly NODE_VERSION="20"
readonly NODE_MIN_VERSION="20.19.0" # backend engines: ^20.19.0 || >=22 (sharp 0.35, html-to-text 10)
readonly NODE_VERSION="22"
readonly NODE_MIN_VERSION="22.12.0" # backend engines: >=22.12.0 (sanitize-html 2.17.7)
readonly MIN_RAM_DOCKER=2048
readonly MIN_RAM_NATIVE=1024
readonly MIN_DISK_GB=2
@@ -721,6 +721,13 @@ EOF
# Native Installation
################################################################################
# True when a Node.js version satisfies the backend's engines range (>=22.12.0).
node_version_supported() {
local ver="$1"
[[ "$ver" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || return 1
[[ "$(printf '%s\n' "$NODE_MIN_VERSION" "$ver" | sort -V | head -1)" == "$NODE_MIN_VERSION" ]]
}
install_nodejs() {
# --update dispatches here before main() runs detect_os, so detect on demand
if [[ -z "$PACKAGE_MANAGER" ]]; then
@@ -729,8 +736,8 @@ install_nodejs() {
local node_ver
node_ver=$(command_exists node && node -v | cut -d'v' -f2 || echo "0")
# backend engines range is ^20.19.0 || >=22 (Node 21 is excluded by the glob/minimatch family)
if [[ "$(printf '%s\n' "$NODE_MIN_VERSION" "$node_ver" | sort -V | head -1)" == "$NODE_MIN_VERSION" && "${node_ver%%.*}" != "21" ]]; then
# Match sanitize-html's declared Node minimum, including strict npm installs.
if node_version_supported "$node_ver"; then
log_success "Node.js $(node -v) is already installed"
return
fi
@@ -748,10 +755,10 @@ install_nodejs() {
;;
esac
# Package managers won't downgrade a newer Node (e.g. 21), so re-verify before continuing
# Re-verify in case the package manager did not replace an unsupported Node.
node_ver=$(command_exists node && node -v | cut -d'v' -f2 || echo "0")
if [[ "$(printf '%s\n' "$NODE_MIN_VERSION" "$node_ver" | sort -V | head -1)" != "$NODE_MIN_VERSION" || "${node_ver%%.*}" == "21" ]]; then
die "Node.js v$node_ver does not satisfy the backend requirement (^$NODE_MIN_VERSION || >=22); remove the current Node.js, install a supported version, then re-run this script"
if ! node_version_supported "$node_ver"; then
die "Node.js v$node_ver does not satisfy the backend requirement (>=$NODE_MIN_VERSION); remove the current Node.js, install a supported version, then re-run this script"
fi
log_success "Node.js installed: $(node -v)"
}