Compare commits

...

115 Commits

Author SHA1 Message Date
Paul Nothaft 6af5241c15 docs: align stable security and backport policy 2026-09-07 23:13:05 +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
Paul Nothaft c05faa50d9 chore(stable): release 3.46.7 (#1221)
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-08-29 03:25:38 +02:00
Paul Nothaft 15c844db06 fix(admin): the "Uncategorized" photo filter returns every photo (#1211) (#1215)
Stable twin of the same fix on main.

The dropdown rendered as `value="0"` and adminPhotos.js:1001 skips '0', so no
category condition was applied and the whole event came back. The branch that
does the work sits four lines below, keyed on the literal 'uncategorized' that
nothing was sending.

Silent by nature — a full list reads as 'nothing to narrow' rather than 'the
filter did not run' — which is why it survived this long.

The reporter in #1209 is on 3.46.4, so this is the branch that reaches them.

Tests both ends of the contract, since the bug was the pairing rather than
either half.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-28 08:05:21 +02:00
Paul Nothaft c685a3e931 chore(stable): release 3.46.6 (#1207)
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-08-28 04:00:46 +02:00
Paul Nothaft 74ff236b51 fix(images): fence the capture-date backfill on the file it read (#1201) (#1205)
Stable twin of #1204.

The capture-date backfill committed its result keyed on the row id alone. It
snapshots every candidate up front, then walks them one at a time reading
originals off S3 or a NAS mount — a pass that can run for many minutes.

replacePhoto, reachable from the replace_by_name upload path, swaps a NEW file
under an existing row and rewrites path/filename. A replacement landing inside
that window carries no date of its own, so captured_at was still NULL, the
whereNull guard passed, and the previous file's EXIF date was written onto the
new photo. Silent: nothing errored, the run reported it as a success, and the
gallery just sorted that photo to the wrong place.

Fenced on path and filename as well as the id, so a replaced row matches zero
rows and is skipped. The candidate query already selects both columns, so no
query change. Knex renders a null value in the object form as `is null` on both
the pg and sqlite3 clients, so a row with a NULL path still matches itself.

Those skipped candidates are now counted rather than dropped. replacePhoto is
not the only writer of path/filename — eventRenameService rewrites both on an
event rename, which is not a content change — and another writer filling
captured_at first lands in the same place. Without a counter they fell out of
the run's arithmetic entirely: success + noExif + failed no longer added up to
the count the operator was shown when they started the job.

The card shows the count only when it is non-zero, and states what is known —
changed by something else, not updated — rather than promising a retry: for the
already-dated case there is nothing to retry, and the Missing Capture Date
figure above is what says whether work is left. Locale coverage: en, de, fr, sl,
with the defaultValue carrying the rest.

Regression test: a replacement landing mid-run leaves captured_at NULL and is
not counted as updated. Verified to fail against the unfenced code on this
branch.
2026-08-27 08:44:32 +02:00
Paul Nothaft 292dd4fa09 chore(stable): release 3.46.5 (#1193)
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-08-26 21:44:17 +02:00
Paul Nothaft 5559cd333d fix(images): respect EXIF orientation in thumbnails, heroes, previews and watermarks (#1185) (#1202)
Stable twin of #1194.

generateThumbnail, generateHeroImage and generatePreviewImage went straight
from sharp(imagePath) to .resize(), so a photo whose Orientation tag is not 1 —
routine for portrait shots on bodies that tag rather than rotate the sensor
data — was resized from the raw frame and came out sideways. The same pipelines
then call .withMetadata(false), stripping the tag from the output, so nothing
downstream could correct it either. The download path already had this right,
which is why the same photo looked correct on download and rotated in the
gallery.

watermarkService had it too, and it is the one a guest actually sees:
gallery.js serves photos.watermark_path ahead of the original when branding
watermarking is on. Two details there — metadata() is read from a separate
unrotated handle, because .rotate() does not change what it reports and every
use of those numbers is positioning; and the composite offsets are floored,
because getPositionCoordinates returns fractional pixels, sharp rejects them,
and applyWatermark catches its own error and silently returns the image
unwatermarked.

The rotate is unconditional in the thumbnail and hero generators — neither
passes `animated: true`, so both already flatten a multi-frame source and
guarding there would protect an animation that was being discarded anyway.
generatePreviewImage keeps the guard, since it genuinely does preserve
animation.

photos.width/height were stored from sharp's metadata, which reports pixels as
STORED, not displayed. For orientation 5-8 those are swapped, so a portrait
photo landed in the database as landscape and masonry sized its tile with the
wrong aspect ratio on top of the image being unrotated. A shared
orientedDimensions() helper now does the conversion at all eight image ingest
sites. The video path is deliberately untouched: its dimensions come from
ffprobe, where EXIF orientation does not apply.

Existing rows keep their pre-rotation dimensions until reprocessed; the backfill
for those is #1199 on main and is not ported here yet.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 21:13:08 +02:00
Paul Nothaft ac7ef266dc fix(admin): make "Storage used" report storage used (#1164) (#1177)
* fix(admin): make "Storage used" report storage used (#1164)

Stable twin of #1170.

The tile summed photos.size_bytes — the catalogued size of the ORIGINALS,
which in reference mode live on external storage and have no relationship to
the disk PicPeak runs on. The reporter's tile read ~80 GB against 21 GB of
real usage. Worse than the label: the same number drove the soft-limit warning
bar and, via /storage/info, the recommended soft limit — so a reference-mode
install got a disk-capacity recommendation computed from bytes that are not on
the disk.

- new localStorageUsage service walks the storage root and reports the total
  plus a breakdown. Walking rather than summing DB columns is the point:
  thumbnail/preview/hero rows record a key and never a byte count, and orphans
  from a deleted event or an interrupted import are real bytes.
- the external media root is excluded when it sits inside the storage root.
  Its compose default is <storage>/external-media, where the NAS is
  bind-mounted — a plain directory, not a symlink — so walking it would put
  every referenced original back into a figure whose purpose is to leave them
  out. Symlinks are not followed either.
- .download-cache gets its own line: it lives inside the event directory, so
  the naive rule files a multi-GB zip as photography.
- concurrent cold-cache callers share one walk; the dashboard, /storage/info
  and the sidebar are routinely requested together.
- S3 installs keep the catalogued figure and the walk is skipped before it
  runs, since the objects are in the bucket and STORAGE_PATH holds only
  incidental local files.
- an absent measurement reads as "unavailable" and a partial one is marked
  `+` across the dashboard, analytics, sidebar and status tab — a floor
  silently compared against a soft limit reads as "safely under".

Verified on this branch: 11 new service tests, dashboardScope updated for the
changed contract, full suite leaves the same 5 pre-existing failures as
origin/stable. Frontend 20 files / 104 tests, tsc clean.

* fix(admin): tell "no disk to measure" apart from "the measurement failed" (#1164)

External review found both of these on this branch.

Both were reported as `storage_measurement: 'catalog'`, so a failed local walk
made the dashboard claim the objects live in S3. They are different things —
one is a fact about the install, the other is a fault — and there is now an
`unavailable` state for the second.

The analytics percentage could reach the billions. `safeSoftLimit` fell back to
`storageUsed || 1`, and on S3 that is null → 1, while the figure beside it came
from `catalogedBytes`. An editor or viewer holds `analytics.view` but not
`settings.view`, so `/storage/info` 403s for them and `storageInfo` is
undefined — which is exactly when that fallback fires. It now falls back to the
measured figure, and suppresses the percentage entirely when there is no real
limit rather than dividing usage by itself and always reading 100%.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 09:14:48 +02:00
Paul Nothaft 9ffbe2f98f fix(previews): preserve alpha and animation in the preview tier (#1176)
* fix(gallery): stop the lightbox loading originals to display a photo (#1166)

Stable twin of #1169.

The lightbox read preview_url, which the server only emits once an admin has
flipped lightbox_preview_enabled — off by default. So a stock install fell
straight through to url, the untouched original: a reporter measured 16.5 MB
for a photo whose preview is 345 KB. The lightbox renders its neighbours too,
so opening one photo pulled three originals.

slideshow_url is the same /preview/:id URL, watermark query included, and has
been emitted unconditionally for images since #1015. Preferring it fixes every
existing install with no migration and no admin action.

Two other surfaces bypass PhotoLightbox entirely and had the same bug:

- premium galleries build their own slides with `src: photo.url`. Fixing that
  also required carrying the photo id on the slide, because the download
  handler recovered the photo by matching slide.src against photo.url — a
  derivative src would have made Download a silent no-op.
- the Story layout rendered the full original as its GRID TILE, at
  object-cover in a small card, and its hero rendered one as a full-bleed
  background when hero_url exists for exactly that. Cards now use the preview
  tier (not the thumbnail: thumbnail_fit is seeded to 'cover', so a thumbnail
  would be cropped a second time and reframe every photo) and only load once
  within 200px of the viewport, since every card mounts at page load.

GIF, APNG and PNG keep the original: generatePreviewImage encodes JPEG, which
has neither a second frame nor an alpha channel. The backend fix that removes
this list is the next commit in this stack.

Divergence from the main twin: no responsive `?w=` tiers. #1095 is main-only,
so `lightboxImageUrl` here selects a URL and nothing more. It lives in
`imageTiers.ts` under the same path main uses, so that backporting #1095 later
merges into this file rather than landing beside it.

Verified on this branch: 8 new tests; frontend suite 21 files / 113 tests,
tsc clean.

* fix(gallery): make the Story hero fix actually work on external galleries (#1166)

External review. Same two fixes as the main twin.

hero_url was inert for external media. ensureHeroImage only ever called
resolvePhotoStorageKey, which returns null for external/reference photos by
design — and that null was handed straight to withLocalCopy, which throws, so
the hero route caught it and redirected to the full ORIGINAL. #1078 fixed
exactly this shape for ensurePreviewImage and nobody carried it across. It
stayed invisible until this PR pointed the Story hero at hero_url: on a
managed gallery that is a real saving, on a reference-mode gallery it quietly
changed nothing.

Needed one extra piece here that main already had: generateHeroImage on this
branch ignores outputBasename and always derives the key from the source
basename, so two events referencing the same NAS filename would clobber each
other's hero. It now honours the option, matching generateThumbnail and
generatePreviewImage.

The format bypass trusted mime_type, which is not trustworthy: migration 039
backfilled every pre-existing photo to image/jpeg regardless of what it was,
and adminExternalMedia inserts rows with no mime_type at all — so a
mislabelled PNG sailed past the guard and came back flattened. It now checks
the filename extension as well.

* fix(previews): preserve alpha and animation in the preview tier

Stable twin of #1171. Stacked on the #1166 twin, whose format bypass this
removes.

generatePreviewImage encoded JPEG unconditionally. JPEG has no alpha channel
and no second frame, so a transparent PNG came back flattened onto a solid
background and an animated GIF came back as its first frame — for every
consumer of this tier, not just the lightbox. It was only invisible by default
because the lightbox served originals.

Sources with alpha, or more than one page, are now encoded as WebP, which
carries both and is still far smaller than the original. Ordinary photos stay
JPEG.

- the output extension matches what was written. A PNG source previously
  produced `preview_foo.png` holding JPEG bytes; harmless while the route
  hard-coded image/jpeg, wrong once the encoding varies. Existing keys keep
  working — they are still JPEG and still served as such.
- the preview route derives Content-Type from the key. With nosniff set,
  mislabelling would show a broken image rather than being silently corrected.
  The watermark branch re-encodes to JPEG and now says so.

The frontend guess-by-MIME goes away entirely, including the case it could
never get right: a still and an animated WebP declare the same type.

Divergence from the main twin: no width-tier case. The responsive `?w=`
renditions (#1095) are main-only, so this branch has a single canonical
preview per photo.

Verified on this branch: 5 new backend tests against real Sharp output;
frontend 21 files / 114 tests; full backend suite leaves the same 5
pre-existing failures as origin/stable.

* fix(previews): retire the legacy preview keys, and stop mislabelling watermarked ones

External review. Same two defects as the main twin.

Legacy keys collide with the new naming. The old generator kept the SOURCE
basename verbatim while always writing JPEG, so a `.webp` upload produced
`previews/preview_shot.webp` holding a JPEG. The claim that pre-existing keys
have no .webp suffix was simply wrong. The route now derives Content-Type from
the key and the response carries nosniff, so every photo uploaded as WebP
would have rendered as a broken image in the lightbox. Legacy `.png` keys are
wrong the other way: flattened JPEGs of what may have been transparent
sources, which isPreviewValid would have let stand forever.

Migration 178 clears photos.preview_path outright — all of it, not just the
suspicious extensions, because a `.jpg` key can equally be a flattened
rendition and nothing in the key says so. Previews regenerate lazily on next
view under the new encoder.

The watermark branch mislabelled its output. applyWatermark PRESERVES the
source format on this branch too (watermarkService.js: png stays png, webp
stays webp), and its input is the preview — so the output already matches the
key the header was derived from. Forcing image/jpeg mislabelled every
watermarked WebP preview, and nosniff means the browser would not correct it.

Numbered 178, not 176: this stack does not carry the external-media
migrations, but that stack takes 176 and 177 on this same branch, and two
files sharing a numeric prefix would be confusing even though both would run.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 09:14:42 +02:00
Paul Nothaft 75facb4d67 fix(gallery): stop the lightbox loading originals to display a photo (#1166) (#1175)
* fix(gallery): stop the lightbox loading originals to display a photo (#1166)

Stable twin of #1169.

The lightbox read preview_url, which the server only emits once an admin has
flipped lightbox_preview_enabled — off by default. So a stock install fell
straight through to url, the untouched original: a reporter measured 16.5 MB
for a photo whose preview is 345 KB. The lightbox renders its neighbours too,
so opening one photo pulled three originals.

slideshow_url is the same /preview/:id URL, watermark query included, and has
been emitted unconditionally for images since #1015. Preferring it fixes every
existing install with no migration and no admin action.

Two other surfaces bypass PhotoLightbox entirely and had the same bug:

- premium galleries build their own slides with `src: photo.url`. Fixing that
  also required carrying the photo id on the slide, because the download
  handler recovered the photo by matching slide.src against photo.url — a
  derivative src would have made Download a silent no-op.
- the Story layout rendered the full original as its GRID TILE, at
  object-cover in a small card, and its hero rendered one as a full-bleed
  background when hero_url exists for exactly that. Cards now use the preview
  tier (not the thumbnail: thumbnail_fit is seeded to 'cover', so a thumbnail
  would be cropped a second time and reframe every photo) and only load once
  within 200px of the viewport, since every card mounts at page load.

GIF, APNG and PNG keep the original: generatePreviewImage encodes JPEG, which
has neither a second frame nor an alpha channel. The backend fix that removes
this list is the next commit in this stack.

Divergence from the main twin: no responsive `?w=` tiers. #1095 is main-only,
so `lightboxImageUrl` here selects a URL and nothing more. It lives in
`imageTiers.ts` under the same path main uses, so that backporting #1095 later
merges into this file rather than landing beside it.

Verified on this branch: 8 new tests; frontend suite 21 files / 113 tests,
tsc clean.

* fix(gallery): make the Story hero fix actually work on external galleries (#1166)

External review. Same two fixes as the main twin.

hero_url was inert for external media. ensureHeroImage only ever called
resolvePhotoStorageKey, which returns null for external/reference photos by
design — and that null was handed straight to withLocalCopy, which throws, so
the hero route caught it and redirected to the full ORIGINAL. #1078 fixed
exactly this shape for ensurePreviewImage and nobody carried it across. It
stayed invisible until this PR pointed the Story hero at hero_url: on a
managed gallery that is a real saving, on a reference-mode gallery it quietly
changed nothing.

Needed one extra piece here that main already had: generateHeroImage on this
branch ignores outputBasename and always derives the key from the source
basename, so two events referencing the same NAS filename would clobber each
other's hero. It now honours the option, matching generateThumbnail and
generatePreviewImage.

The format bypass trusted mime_type, which is not trustworthy: migration 039
backfilled every pre-existing photo to image/jpeg regardless of what it was,
and adminExternalMedia inserts rows with no mime_type at all — so a
mislabelled PNG sailed past the guard and came back flattened. It now checks
the filename extension as well.

* test(gallery): the hero fixture follows the root-relative relpath contract (#1166)

Same fix as the main twin: external_relpath has been resolved from
EXTERNAL_MEDIA_ROOT rather than from event.external_path since #1163 landed,
and this fixture still carried the base-relative form, so the two tests stopped
resolving the moment that stack merged. Production was never affected.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 09:08:50 +02:00
Paul Nothaft 58ccecc304 fix(admin): move the maintenance sweeps' run state into the database (#1181) (#1188)
Stable twin of #1184. Both photo sweeps tracked whether they were running in a
module-level variable, which is invisible to every other replica: a status poll
routed to an idle replica reports isRunning false while another is mid-run, and
the next POST starts a second pass over the whole library.

Migration 179 adds one row per job, claimed with a conditional UPDATE whose
affected-row count is the answer. The lease is fenced on a per-claim token so a
runner superseded by a stale takeover cannot renew a claim it has lost or
release one it no longer owns; renewal runs on a timer spanning the claim
through release, since one hung NAS read can outlast the stale window inside a
single iteration. maintenance_jobs is excluded from .picpeak archives.

Gated on settings.edit / settings.view rather than main's system.manage, which
does not exist on this branch — they are what settings.edit was later split
into, so both branches let the same people through.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 09:08:14 +02:00
Paul Nothaft 7f0ed23ea4 fix(external-media): record captured_at on import and add a backfill (stable) (#1183)
* fix(external-media): record captured_at on import and add a backfill (#1172)

Stable twin of #1179.

External media never went through photoProcessor, so captured_at stayed NULL
for every externally imported photo. The gallery's "Date Taken" sort then
degraded into import order through its own COALESCE fallback — a library
imported in two batches showed the first days of a trip after the last ones.

Ported whole:
- adminExternalMedia.js reads the capture date at import, off the file it has
  already opened for the dimensions. Best-effort, like the dimensions.
- A backfill endpoint for photos imported before this, so existing installs
  can fix historical rows rather than only new imports. Managed originals go
  through resolvePhotoStorageKey + withLocalCopy so S3 installs work; archived
  events are excluded because archiving deletes their originals; the run flag
  is claimed before the candidate query so two POSTs cannot both start.
- gallery.js carries photos.id as a tiebreaker on all three sorts. A bulk
  import writes hundreds of rows inside the same second, so uploaded_at ties
  are the normal case and the grid reshuffled between page loads.

One deliberate difference from main: the backfill is gated on settings.edit /
settings.view rather than system.manage / system.view, which do not exist on
this branch. They are what settings.edit was later split into, and main's
migration 175 projects every settings.edit holder forward onto system.manage,
so both branches let exactly the same people through.

* fix(external-media): gate the status card on the permission the button needs (#1172)

The built-in admin role holds settings.view but not settings.edit
(056_add_role_permissions_table.js:63), and StatusTab renders its card and
enabled button purely on a successful status payload. Gating the status
endpoint on settings.view therefore showed every admin a Backfill button whose
every click 403s with no error surfaced.

* fix(gallery): make the Date Taken sort correct on SQLite (#1172)

Same defect as the main twin: photos.captured_at holds three storage classes on
SQLite — an epoch-millisecond integer from managed uploads (photoProcessor.js:441
hands knex a Date), ISO text from external imports and the backfill, and null
falling through to uploaded_at's 'YYYY-MM-DD HH:MM:SS' text. SQLite sorts
INTEGER before TEXT unconditionally, so a 2027 capture came back before a 2020
one, and within the text values the 'T' separator outranked the space.

Normalised in the ORDER BY; Postgres keeps the plain COALESCE, its column being
a real timestamp. Regression tests drive the real gallery route on real SQLite.

* fix(gallery): normalise epoch-integer uploaded_at too, and stop polling a 403 (#1172)

Both follow-ups from the main twin's review, ported.

uploaded_at is not always text on SQLite: a .picpeak restore can carry epoch
milliseconds in from an install that stored them that way, and the fallback
branch read it with substr(), comparing '1830297600000' against
'2020-01-01 00:00:00' as text. Both columns now get the integer/real branch.

The status card also polled every ten seconds regardless of permission. On this
branch that hits every built-in admin — they hold settings.view but not
settings.edit — so each would have had a 403 and a logged denial every ten
seconds for a panel they were never shown.

* style: quote convention in the capture-sort test (#1172)

* fix(capture-dates): skip watcher-imported videos, and make the status counts consistent (#1172)

All three follow-ups from the main twin, ported: the three-marker video filter
(fileWatcher sets type/mime but not media_type, so those rows sat in the
backlog forever), the single-aggregate status counts (two queries could report
a negative backlog mid-import), and the card's render gated on settings.edit as
well as the cached payload.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 09:04:18 +02:00
Paul Nothaft 2b1c3588ae fix(external-media): store external paths from the media root (#1163) (#1174)
* fix(external-media): store external paths from the media root (#1163)

Stable twin of #1168. Stacked on the #1162 twin, which supplies
deleteDuplicatePhotos.

Importing a second folder into an event silently invalidated every photo
already in it. external_relpath was stored relative to events.external_path,
and every import overwrites that column, so the older rows were rebased onto
the new folder. Nothing errored and the grid still rendered — thumbnails are
written to local storage during the import while the base path is still
correct — so only the things that need the original broke. The reporter had
7547 of 8004 rows pointing into the void.

- external_relpath is now relative to EXTERNAL_MEDIA_ROOT, so a row is
  self-describing and nothing an admin does to the event can move it.
- migration 177 folds each event's base into its rows. Where the current
  resolution is missing it walks up for an ancestor holding a file of the same
  name AND the size the import recorded — existence alone would let a deleted
  file adopt an unrelated namesake and serve the wrong original. Rows it
  cannot place keep resolving where they resolve today, and the probe is
  skipped entirely when the mount is unreachable.
- probing is read-only and runs first; the rewrites and the marker commit
  together, so an interrupted fold cannot be folded twice.
- rewrites are staged through a per-row parking value, because a final path
  can equal another row's current one; and migration 177 re-throws without the
  driver's error code, which run-migrations-safe would otherwise read as
  "schema already exists".
- the fold also runs after a .picpeak restore, since knex_migrations is
  excluded from the archive, and a failure there is reported rather than
  presented as a clean restore.
- drops the duplicate-leaf-segment guess in photoResolver, which papered over
  this same double-prefixing.

Divergence from the main twin: no face-scan requeue reordering. Face
recognition is main-only, so the hazard of queueing rows against unconverted
paths does not exist on this branch — in picpeakImportService or in
restoreService.

Verified on this branch: 23 new tests pass, and the four suites carrying
base-relative fixtures were updated. Full suite leaves the same 5 pre-existing
failures as origin/stable, unchanged.

* fix(external-media): the fold's staging value must be storable on Postgres (#1163)

External review found this on this branch first; it was on both.

The two-pass rewrite parks each row on a temporary value, and that value was
written with a leading NUL. SQLite stores NUL in TEXT without complaint;
Postgres rejects it outright — "invalid byte sequence for encoding UTF8: 0x00"
— so migration 177 rolled back on exactly the installs that need the two-pass
repair, and only on the engine most of them run. Restores hit the same wall
and reported the conversion as failed.

The prefix is ordinary text now. Adds a gated Postgres test alongside the
existing picpeakRestorePg one, because a SQLite-only suite structurally cannot
catch this class: restoring the NUL makes exactly the two-pass repair case
fail with that error, and nothing else.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 09:00:05 +02:00
Paul Nothaft e9fcf4960e fix(external-media): one row per external file per event (#1162) (#1173)
* fix(external-media): one row per external file per event (#1162)

Stable twin of #1167.

Two overlapping import-external runs against the same event inserted every
file twice. The route checked for an existing external_relpath and then
inserted, with an fs.stat and a sharp().metadata() read sitting in between — a
window wide enough for both runs to see "not there". A reporter's event held
8004 rows for 6012 distinct paths. Nothing at the storage layer stopped it:
migration 041 created only a NON-unique (event_id, source_origin) index.

- migration 176 removes the existing duplicates and adds a partial unique
  index on (event_id, external_relpath), verified against the catalog
  afterwards — a failed CREATE INDEX raises 23505 on Postgres, which
  run-migrations-safe treats as "schema already exists" and would record as
  applied on an install that never got the index.
- dependent rows are removed explicitly rather than by cascade: PicPeak never
  sets `PRAGMA foreign_keys = ON`, so on SQLite the declared CASCADE is inert
  and a bare delete strands feedback and access-log rows. Guest feedback moves
  to the survivor instead of being discarded, keyed on guest identity the way
  feedbackService defines it, and the survivor's denormalized counters are
  recomputed.
- the route treats a unique violation as a skip, so a writer this process
  cannot see converges instead of duplicating, and a second import while one
  is running gets a 409.
- a .picpeak taken before migration 176 carries exactly these duplicates, and
  suspending FK enforcement does not suspend a unique index — so the restore
  drops the index for the load and rebuilds it after running the same dedupe.

Divergences from the main twin, both because the feature is absent here:
faces (no faceProcessor, so no purgePhotoFaces reconciliation — the rows are
still deleted so nothing dangles), admin marks, transfer membership, and
photos.view_count/download_count. The service guards each on hasTable /
hasColumn, so those branches simply do not fire.

Verified on this branch: 36 new tests pass; full suite leaves the same 5
pre-existing failures as origin/stable, unchanged.

* fix(external-media): invalidate the download zip when duplicates are removed (#1162)

External review. Same fix as the main twin.

The pre-built "download everything" archive still contained the duplicate rows
the dedupe had just deleted, so guests kept receiving them. Every ordinary
photo-deletion path calls downloadZipService.invalidate for exactly this
reason.

The columns are cleared rather than the service being called: that service
carries debounce timers and a regeneration queue, which a migration should not
start. getZipInfo already treats a cleared record as a cache miss and rebuilds
on the next request. The stale object is left in storage, as elsewhere.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-26 08:55:17 +02:00
Paul Nothaft f83d144f28 chore(stable): release 3.46.4 (#1159)
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-08-24 20:34:25 +02:00
Paul Nothaft b62cd2c290 fix(gallery): a guest's own hidden feedback is hidden from them too (#1150) (#1157)
Stable twin of #1153.

Everything in the system treats a hidden row as absent, but the per-viewer is_liked heart read the row without looking at is_hidden — so a like the photographer had hidden still showed as liked on a photo whose like_count was zero.

Making that agree exposes the second half: the duplicate check behind like/favorite toggling did not skip hidden rows either, so the now-empty heart, when clicked, found the hidden row and toggled it OFF. Skipping hidden rows there makes the click create a fresh, visible row.

Also carried from review: the per-guest caps, /my-feedback and getEventFeedbackSummary no longer count hidden rows, and unhiding collapses the guest's replacement — skipped when there is no stable identity, since that fallback was 'guest_identifier IS NULL', i.e. other visitors' rows.

Not carried: the my_color_label badge (colour labels are #1044) and the clearScope / singleValueScope visibility fix, neither of which exists on this branch.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:09:41 +02:00
Paul Nothaft eaa8b41ba3 fix(gallery): guest filters respect show_feedback_to_guests (#1044) (#1156)
Stable twin of #1147, filter half only.

Every filter token on /photos is an OR of two halves: what THIS viewer marked, and what ANYONE marked. The fields built from the second half are gated on show_feedback_to_guests; the filter was not, so with the setting off ?filter=liked still returned exactly the photos other people liked — the membership instead of the count, one token at a time.

The half it left standing was also the wrong half: it read guest_identifier from the guest_id query parameter, which never matched anything, and accepting a caller-supplied identifier was a way back through the gate. Resolved from the request now, hidden rows excluded to match what the viewer can see.

Not carried: the color: token and the photo_admin_marks concurrent-write fix — colour labels and admin marks are not on this branch.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:08:23 +02:00
Paul Nothaft d46397d92a fix(gallery): no Logout button on galleries that don't require a password (#1149) (#1154)
Stable twin of #1152. The reporter is on v3.46.1, so this branch is where the bug was actually seen.

showLogout was hard-coded true, so a gallery with no password showed a Logout button, and clicking it stranded the visitor on the loading skeleton — GalleryPage's auto-login is a one-shot latch that never re-fires.

The button is gated at both call sites, including the full-page layouts which render it on the callback rather than a flag. accessLevel and viaCustomer now come from /auth/session instead of per-tab sessionStorage, which silently downgraded a PIN-client session in a second tab. The public-gallery branch shows a reason and a Retry once auto-login has run and failed, instead of a skeleton that never stops.

Carries the full main fix including viaCustomer, even though reveal mode does not exist here, so the branches do not drift.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:07:05 +02:00
Paul Nothaft e46260ad07 fix(scripts): regenerate-thumbnails resolves external sources through ensureThumbnail (#1148) (#1155)
Stable twin of #1151.

The script here carried the identical defect: it computed `storage/events/active/<photo.path>` and fs.access'd it, which does not exist for external or reference rows. #1129 already landed on this branch, so the route was fixed and the script was the remaining half.

Resolution goes through ensureThumbnail, which stable already exports with the external branch intact. Also carried: videos skipped on every marker, skip-vs-generate asked from isThumbnailValid, and a nonzero exit when a photo could not be built.

Not carried: the responsive-tier backfill — THUMBNAIL_WIDTHS and ensureThumbnailAtWidth are #1095/#1109 and do not exist on this branch.

Merged with admin privileges: the author cannot self-approve.
2026-08-23 22:06:30 +02:00
Paul Nothaft e9fadd2ef4 chore(stable): release 3.46.3 (#1143)
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-08-23 20:23:29 +02:00
Paul Nothaft d977e3e296 fix(gallery): give masonry tiles their real shape back (#1130, #1131)
Two independent causes of the same symptom — an aspect-ratio layout that does
not lay anything out.

gallery-premium discarded the tile height MasonryPhotoAlbum computed from
photos.width/height and set height:auto on both card and image, so the rendered
shape came from whatever rendition was served. With thumbnail_fit seeded 'cover'
every rendition is square, so the masonry drew identical squares.

The bundled CSS templates pinned images to a fixed pixel height, which beats the
.h-full utility six of the seven layouts rely on. Elegant Dark is seeded
is_default, so that was the out-of-the-box result for any layout other than
grid/timeline.

Migrations 052/053 corrected for fresh installs; 175 repairs the rows already
seeded. The repair is whitespace-tolerant because sanitizeCSS strips newlines
from any template ever saved through the editor, matches the height property
with a lookbehind so line-height/max-height are untouched, handles grouped
selectors and skips nested rules.

Stable twin of #1135.
2026-08-22 21:36:51 +02:00
Paul Nothaft da44f1947b fix(gallery): a missing file must not take the backend down (#1128)
LocalFsStorage.get() returns an fs.createReadStream, which is lazy: it resolves
immediately and opens the file on a later tick, so an ENOENT arrives after the
await returned and outside the route's try/catch. An unhandled 'error' event is
a process-level throw Express cannot catch — the backend exits and every gallery
goes blank until the container restarts.

gallery.js had ten .pipe(res) calls and zero error handlers.

pipeStreamToResponse attaches the missing handler: a vanished source becomes a
404 (410 for a prepared zip), anything else a 500, and a source that dies
mid-response destroys the connection rather than rewriting a status already on
the wire. Headers staged for the file are cleared first — Express does not
overwrite an existing Content-Type, and a surviving Cache-Control would let a
transient 404 be cached as a broken tile for up to an hour. It also releases the
source when a client hangs up.

Applied to all eight streaming responses, not just the thumbnail route.

Stable twin of #1133, reduced: the tier-race half does not apply here because
ensureThumbnailAtWidth does not exist on this branch.
2026-08-22 21:36:43 +02:00
Paul Nothaft dc9e3cdc5e fix(thumbnails): regenerate external photos, and stop destroying good ones (#1129)
POST /admin/thumbnails/regenerate resolved every source as
storage/events/active/<photo.path> and fs.access'd it. External and reference
rows are not there — their originals live under events.external_path — so every
one failed the check and was counted as an error, while the UI reported success
because the response is sent before the background loop starts.

It now goes through ensureThumbnail, which resolves both source kinds and writes
thumbnail_path back itself. Nulling thumbnail_path stops it short-circuiting on
isThumbnailValid, which matters because the old thumbnail is normally still
readable at exactly the moment someone presses regenerate.

And the half that destroys data: generateThumbnail deleted the target BEFORE
sharp had opened the source, and again in its catch. A source that could not be
read — a NAS mount that blipped — left the previous rendition gone and the
database pointing at it. Across a bulk regenerate that is the whole gallery.
Neither delete was needed: put stages to a temp file and renames atomically, and
put is the last statement in the try so no partial object can exist.

Also: videos filtered out, and the superseded rendition removed only when the
storage key actually moved, compared through the same canonicalisation the
backends apply.

Stable twin of #1134.
2026-08-22 21:36:19 +02:00
Paul Nothaft 7598e20f55 chore(stable): release 3.46.2 (#1121)
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-08-21 20:30:47 +02:00
Paul Nothaft 32db1c8052 fix(ui): stop iOS Safari zooming in on 14px form fields (#1114)
Closes #1105.

iOS Safari zooms the whole page in when a focused form control computes to
under 16px, and it does not zoom back out. Unlocking a gallery is a
client-side transition rather than a document navigation, so the zoom the
password field triggers carries straight into the gallery: the layout pans
horizontally and the header actions sit off-screen until the visitor
pinch-zooms out by hand. A real page load would have reset it.

`.input` and `.input-themed` are `text-sm`, so the field is 14px on every
phone, and GalleryPage inverts the breakpoint on top of that
(`text-sm sm:text-base` — 14px below 640px, where iOS zooms, and 16px above
it, where it never does).

Keyed to the POINTER, not a width. The zoom depends on the computed font size
and a touch device, never on how wide the viewport is — and a phone in
landscape is 667-956 CSS px, above any width you could call "phone". Measured
on the admin login page, which has no `sm:` override:

  main   portrait 390x844    14px    zooms
  main   landscape 844x390   14px    zooms
  main   iPad 820x1180       14px    zooms
  fixed  all three           16px
  fixed  desktop (mouse)     14px    unchanged, no zoom off touch

One media query rather than flipping each call site. `.input` alone backs 334
`<Input>` usages, but there are also ~440 raw inputs, selects and textareas
carrying their own `text-sm`, and Tailwind utilities sit in a later layer than
@layer components — so a fix at the component definition misses most controls
and any new `text-sm` silently reintroduces the bug.

The `:not()` on each selector is load-bearing, not decoration: it buys the
specificity to beat a utility class. Measured in a browser —

  input.text-sm     16px   (0,2,1 beats .text-sm)
  select.text-sm    14px   (0,0,1 loses)
  textarea.text-sm  14px   (0,0,1 loses)

24 selects and textareas in the tree carry `text-sm`, so the bare form would
have left them zooming. Checkbox and radio stay excluded so font-size never
sizes their box.

max(16px, 1em, 1rem) is a FLOOR, not a size. A flat 16px would make controls
that are already bigger smaller: Typography -> Large sets --font-size-base to
18px on body, so anything inheriting it would be clamped down and the setting
quietly ignored. Each term covers a case the others miss:

  normal (body 16)       16px      Large theme (body 18)    18px
  Small theme (body 14)  16px      browser default 20px     20px

The viewport meta is deliberately left alone: `maximum-scale=1` would suppress
the zoom by disabling pinch-to-zoom for everyone.
2026-08-21 19:25:33 +02:00
Paul Nothaft 9833237d37 chore(stable): release 3.46.1 (#1082)
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-08-19 20:29:01 +02:00
Paul Nothaft 83290a0f1a chore(security): ignore unfixed CVEs in Trivy, override deepmerge-ts (#1085)
Stable twin of #1083, scoped to what exists on this branch.

docker-build.yml — set ignore-unfixed on both Trivy steps. Stable has
the backend and frontend legs only (no aio, no ml), so two steps here
against four on main. Base-image CVEs with no released fix are not
actionable: the Dockerfiles already run `apt-get upgrade -y` behind a
CACHEBUST, so a fix lands in the next build automatically. Reporting
them buries anything someone can actually act on.

backend — deepmerge-ts <8.0.0 has a stack-exhaustion advisory
(CVE-2026-40345, high) reached via mailparser -> html-to-text, which
pins ^7.1.5 so npm cannot get there alone. Stable carries the same
mailparser ^3.9.9 and the same 3-high exposure as main. Not reachable
in our code: html-to-text only feeds deepmerge-ts its options object,
never parsed email content. npm audit on this branch goes 3 high -> 0.

The ml/Dockerfile half of #1083 has no counterpart here — the face
sidecar does not exist on stable, so there is nothing to drift.

Verified on stable itself rather than assuming main's results carry:
npm audit 3 high -> 0, html-to-text exercised end-to-end through
simpleParser, and jest at 1577 passed. The 5 failing suites (20 tests)
fail identically on clean origin/stable with these changes stashed.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-19 13:56:10 +02:00
Paul Nothaft 6df42ab22c fix(preview): generate lightbox previews for external/reference photos (#1078) (#1080)
* fix(preview): generate lightbox previews for external/reference photos (#1078)

Stable twin of the main-line fix. ensurePreviewImage() resolved its source
only via resolvePhotoStorageKey(), which returns null for external/reference
photos by design — those live on a media mount outside the managed storage
tree. The null went straight into withLocalCopy(), which throws, so the
preview route fell back to redirecting at the full-size original. Galleries
whose photos are all external got no benefit from the preview tier (#492):
guests paid 5-12 MB on every lightbox open.

Add the external branch ensureThumbnail() already has: resolve via
resolvePhotoFilePath() and feed the mount path to generatePreviewImage()
directly, with an ext<id>_ output basename.

generatePreviewImage() on this branch hardcoded path.basename(imagePath) and
ignored options.outputBasename, so it needs the same one-line honouring that
generateThumbnail() already does — without it two events referencing the same
NAS basename collide on one preview key.

Also return null rather than throwing for a row with no source_origin in a
reference-mode event, whose mode falls back to the event's.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

* fix(preview): select the columns the external branch needs on bulk regenerate

POST /api/admin/thumbnails/regenerate-previews selected only id, event_id,
path, media_type, mime_type and preview_path, so photo.source_origin was
undefined by the time ensurePreviewImage branched on it. Every external row in
a reference gallery took the managed path, resolvePhotoStorageKey returned null
for it, and the endpoint reported success while generating nothing.

Add source_origin, external_relpath and filename to the select, plus a
source-inspection test pinning the caller contract and a service-level test
showing a column-starved row is indistinguishable from a managed one.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-19 10:17:48 +02:00
Paul Nothaft 45ffe64b7c fix(storage): write business documents under STORAGE_PATH, not the cwd (#1072)
Stable twin of #1070.

persistDocPdf, the invoice sending and reminder writers, both contract
signature writers and persistSignatureImage built their targets from
`path.join(process.cwd(), 'storage', 'business-docs', ...)` and never
consulted STORAGE_PATH. Both compose files pin STORAGE_PATH=/app/storage
and the image's WORKDIR is /app, so on a stock deployment the two name
the same directory and nothing looked wrong. Point STORAGE_PATH anywhere
else and quotes, invoices, Mahnungen, contracts and signature images
land outside the configured storage root: missed by the backup walker,
invisible to storage accounting, and gone when the container is
replaced.

assertContractPdfPath moves with them. On this branch the writers and
the guard are wrong together, so contract downloads currently work —
migrating the writers alone would have introduced PATH_OUTSIDE_STORAGE
on every newly generated contract. The guard now resolves through
getStoragePath() like the writers, and keeps the legacy cwd root so
contracts written before this still resolve; their absolute paths are
in the database.

Also on the shared resolver: the custom PDF font lookup (a font under
STORAGE_PATH/fonts was never found, and the document silently fell back
to the built-in face) and the two backup diagnostics, which otherwise
inspect a different root than the backup walker when STORAGE_PATH is
unset.

No migration needed — the persisted path is stored absolute.

Verified on this branch, not inferred from main: the new test is 6/6,
and contract/quote/invoice/pdf/safePath suites are 213/213 both before
and after the change.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-18 22:14:49 +02:00
Paul Nothaft 84eab88801 test(e2e): read the admin JWT from the cookie, not the login body (#1073)
Stable twin of #1071.

Three specs acquire an admin token with `const body = await res.json();
return body.token`. On this branch too the admin login sets the JWT as
the httpOnly `admin_token` cookie and responds with `res.json({ user })`
— verified in auth.js on stable, not assumed from main — so the token is
undefined and each spec fails at its first assertion, before exercising
anything it was written to cover.

Cookie and Authorization: Bearer are interchangeable server-side, so the
helpers read the value back out of the context cookie jar and keep
threading it as a Bearer. Every downstream call is unchanged.

Verification is weaker than the main twin's, deliberately: the three
spec files are byte-identical to the ones measured there (0 passed /
6 failed before, 3 passed / 3 failed after, against a live stack), and
they compile and enumerate on this branch. Standing up a full stable
compose stack to re-measure test-only changes was not worth it — say the
word if you want that done before merge.

The remaining failures are UI staleness, not auth, and are not addressed
here. No CI workflow runs tests/e2e on this branch either, which is why
this rotted unnoticed.

Claude-Session: https://claude.ai/code/session_01Ra4hcsYiKuQLbbRsg6EjAc

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-18 22:14:15 +02:00
Paul Nothaft 10d5cf54a5 chore(stable): release 3.46.0 (#1060)
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-08-16 20:22:19 +02:00
Paul Nothaft 376311cb90 fix(pdf): RFC 6266-encode Content-Disposition on quote/invoice PDFs (#1024) (#1062)
Stable backport of #1055 (main: 3a11e6eb). Change content is byte-identical
to the main twin; cherry-picked clean, no resolutions needed.

The six quote/invoice PDF endpoints interpolated buildPdfFilename()'s result
straight into `inline; filename="${filename}"`. That result deliberately
preserves non-ASCII (it doubles as the PDF's internal Title metadata), and
HTTP header values are latin1, so a customer label reaching the header
directly failed in one of two ways:

  - U+0080-U+00FF (ä ö ü ß — every German umlaut): no throw. The raw byte
    goes out and the client reads back a mangled name. Silent corruption.
  - above U+00FF (Polish ł, Czech ř, Turkish ş, €, Cyrillic, CJK, emoji):
    Node's setHeader rejects it with ERR_INVALID_CHAR. The throw lands
    after the PDF buffer is already rendered, so the request 500s.

This corrects the issue's diagnosis: it reported umlauts as the 500 case,
but umlauts are inside latin1 and mangle rather than throw.

Route all six through buildContentDisposition(), which emits an ASCII
fallback plus the RFC 5987 filename*=UTF-8'' form. Also stops sanitiseSegment
splitting surrogate pairs at its 80-unit cap — a dangling high surrogate makes
encodeURIComponent throw URIError inside the helper, reaching the same 500 a
different way (found by external review on the main twin).

Verified on this branch: 14/14 in the new suite, 151/151 across the nine
surrounding pdf/filename/quote/invoice suites, lint clean.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-16 19:52:39 +02:00
Paul Nothaft 88fa3c5297 fix(storage): add S3 client timeouts so a dropped connection can't wedge uploads (#1049) (#1054)
Stable backport of #1049 (main: 3600231d). Change content is byte-identical
to the main twin.

S3StorageAdapter built its S3Client with no requestHandler timeouts, and the
SDK's defaults wait indefinitely. When a connection is dropped silently (no
FIN/RST — NAT/LB idle reaps, transient faults), the in-flight request hangs
forever and every subsequent storage operation queues behind it process-wide;
only a restart recovers. _retryOperation never ran because the promise it
wraps never settled.

Configure connectionTimeout (120s) and socketTimeout (60s) on the request
handler, overridable via STORAGE_S3_CONNECTION_TIMEOUT /
STORAGE_S3_SOCKET_TIMEOUT, and add TimeoutError to the retryable list so the
existing backoff engages.

socketTimeout rather than requestTimeout: the latter is a total-duration cap
that would kill legitimate large uploads and only warns without
throwOnRequestTimeout. Both values are deliberately generous — connectionTimeout
covers time queuing for a socket from the agent pool (maxSockets 50), so a
short value expires while merely waiting in line and breaks reads.

Reported against v3.45.16 on Cloudflare R2: wedged roughly every 40 minutes
with serial uploads, every 15-20 with 4 parallel uploaders.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-16 19:23:59 +02:00
Paul Nothaft 980378a17b feat(backup): open sqlite → pg .picpeak restore as the supported upgrade direction (#1041) (#1059)
Stable backport of #1043 (main: 8809564a).

sqlite → pg restore is allowed for anyone holding backup.restore, from the
upload UI and the CLI alike, gated by the manifest-direction rule in
validateManifest. pg → sqlite stays refused, with an error naming the
supported direction. allowEngineSwitch is removed rather than kept alongside:
one gate, no way to drive the refused direction.

Two resolutions were needed against stable rather than a clean cherry-pick,
both from known main/stable divergences:

  - replaceAllTables has no roleSnapshot parameter on this branch, so the call
    keeps stable's 4-arg signature while taking the derived { crossEngine }.
  - resyncSequences was guarded by `if (allowEngineSwitch)`, which this change
    removes — leaving an undefined reference. It now runs unconditionally,
    matching main. That also closes a stable-only gap: a same-engine pg → pg
    restore previously left identity sequences stale, so the next natural
    insert collided on the primary key.

Also exports resyncSequences (the function already existed here, main already
exports it) so the cross-engine suite can drive the post-restore fixup.

Verified on this branch: all four picpeak suites green on SQLite, and 20/20
against a real Postgres 15 with the PICPEAK_PG_TEST_URL-gated cases executing.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-16 19:20:57 +02:00
Paul Nothaft 0a999795cc ci(tests): run the gated real-Postgres .picpeak cases in the backend job (#1058)
Stable backport of #1056 (main: 18b1e0f6). Change content is byte-identical
to the main twin.

The .picpeak restore suites gate their Postgres cases behind
PICPEAK_PG_TEST_URL and describe.skip themselves out when it is unset. The
variable is set in no workflow, so those cases have never run in CI.

On this branch the effect lands together with the #1041 backport, which brings
picpeakCrossEngine.test.js and its three real-Postgres stored-value cases —
stable has no picpeakRestorePg.test.js, so before that PR this wires up a
service nothing reads yet. Merging it first keeps the two twins mirroring
their main counterparts one-for-one instead of folding both into one PR.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-16 19:17:17 +02:00
Paul Nothaft ed4e32c4df chore(stable): release 3.45.16 (#1047)
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-08-13 20:58:32 +02:00
Paul Nothaft 9003b34c8a fix(docker): default NODE_ENV=production so non-compose deploys don't fall back to SQLite (#1038) (#1040)
* fix(docker): default NODE_ENV=production so non-compose deploys don't fall back to SQLite (#1038)

knexfile.js selects its config block by NODE_ENV and the `development` block
defaults to sqlite3. The image never set NODE_ENV, so every deployment that
doesn't go through our compose files — Kubernetes, Helm, plain `docker run` —
silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD.

It stayed invisible because wait-for-db.sh is shell: it reads DB_HOST directly,
connects to Postgres, creates the database and logs "PostgreSQL is up" in the
same container where the Node process then writes to a SQLite file. Migrations
go through src/database/db.js → the same knexfile, so they also ran against
SQLite, leaving the provisioned Postgres database empty.

Setting the default alone would be unsafe: an affected install would flip to
Postgres on its next image pull and come up against an EMPTY database, which
reads as total data loss. So this adds a guard that runs before migrations
touch anything:

  - logs the resolved engine + target at boot
  - refuses to start when pointed at a virgin Postgres while a populated
    SQLite file exists, naming the file and the .picpeak export path for
    moving the data, with PICPEAK_ALLOW_EMPTY_PG=true as the escape hatch
  - warns but boots when Postgres settings are present yet SQLite is in use

Compose files already set NODE_ENV explicitly, so compose users are unaffected.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): stay on SQLite instead of blocking, and add a migration path (#1038)

Reworks the guard after walking through what an existing install actually
experiences on its next image pull.

Blocking was the wrong trade. An operator who had unknowingly been running on
SQLite (because the image left NODE_ENV unset) would have pulled the fix and
got a CrashLoopBackOff: data safe, galleries offline, for something they did
not do. Now the boot RESOLVES the engine before migrations run and stays on
whichever one holds the data:

  - Postgres configured but holding no galleries, while a populated SQLite file
    exists → keep serving from SQLite, print what happened and how to migrate.
  - once Postgres holds the data, the next restart switches over on its own.
  - an explicit DATABASE_CLIENT is always honoured.

Keyed on Postgres holding DATA, not on it having tables: a stray
`run-migrations` against the empty database creates every table, which would
otherwise blind the check.

Adds scripts/migrate-sqlite-to-postgres.js, which reuses the .picpeak
export/import services rather than hand-rolling a cross-engine copy. Two
additions were needed for the SQLite → Postgres direction, both opt-in and
CLI-only so the upload/restore UI is untouched:

  - `allowEngineSwitch` relaxes the importer's same-engine guard
  - cross-engine row coercion: SQLite has no real date or boolean types, so its
    rows carry epoch numbers where Postgres wants a timestamp and 0/1 where it
    wants a boolean, both of which Postgres rejects outright. Driven by the
    TARGET schema, never guessed from the value.

DELTA FROM THE BETA PR: this branch's import service has no resyncSequences()
— that landed on main only. Without it a cross-engine load leaves Postgres
identity sequences at 1 and the next insert collides on the primary key, so
the function is backported here and called ONLY on the cross-engine path.
Same-engine restores through the UI keep their current behaviour exactly.

Verified end to end on this branch against a real PostgreSQL 15: a seeded
SQLite install migrated across with booleans, timestamps and foreign keys
intact, and the next INSERT got id 2 rather than colliding.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close four review findings on the SQLite fallback + migration (#1038)

External review (codex) found four issues, all confirmed against the code and
fixed here. Two of them could have cost data.

1. The engine resolver was reachable only through wait-for-db.sh. A Kubernetes
   manifest that sets `command`/`args`, or a plain `docker run … node
   server.js`, bypasses the entrypoint — exactly the deployment styles this fix
   targets. With NODE_ENV now baked into the image, such an install would have
   resolved to Postgres and come up against an empty database while its SQLite
   data sat there unseen. server.js now resolves the engine itself, before
   anything requires knexfile, via the same script the entrypoint uses.
   Verified by running `node server.js` directly against an install with
   stranded SQLite data: it logs the banner and serves SQLite.

2. Cross-engine loads double-encoded JSON. SQLite has no json type, so its json
   columns are TEXT holding JSON; the export dumps that as a string and
   serialiseJsonColumns stringified it again, storing `true` as the scalar
   string "true". app_settings.setting_value is json on every install, so this
   reshaped every migrated setting. The text is decoded before serialisation
   now — verified against a real Postgres: json_typeof(setting_value) is
   `boolean`, matching a native install exactly.

3. The migration could silently miss concurrent writes. If the backend keeps
   serving, rows written after the export never reach Postgres and vanish from
   view once the engine switches. The script now fingerprints the SQLite tables
   whose loss would be noticed, checks for drift BEFORE loading Postgres (so a
   detected race leaves the target untouched) and again after, and refuses with
   the exact rows that moved. It also says plainly to stop the backend first.

4. The child phases shared stdout with winston. Outside production, and
   whenever LOG_TO_CONSOLE=true, createPicpeak's own log line was concatenated
   with the archive path and the migration failed on a bogus filename. Payloads
   travel through a result file now; verified with LOG_TO_CONSOLE=true.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 2 — six more data-safety findings (#1038)

1. The engine choice is now PINNED once the data is in Postgres. Previously the
   boot decided from "does Postgres hold galleries", so an operator who later
   deleted every gallery would be sent back to the stale pre-migration SQLite
   file while their settings, admins and CRM data stayed in Postgres. The
   migration writes a marker next to the database file (and retires the file
   itself by renaming it); the marker wins over any probe.

2. The migration refused to overwrite Postgres only when it held GALLERIES. A
   target with admins, customers, invoices or projects but no galleries was
   wiped without --force. Both the source and target checks now look for user
   data across the tables that are empty on a fresh install.

3. Same bug in the other direction: an install with no galleries but real
   admins/settings/customers was refused a migration it was entitled to.

4. Drift detection covered four tables and only count/max(id), so an in-place
   UPDATE (event edit, password change) or a write to any other table passed
   unnoticed. It now fingerprints every table the export carries, including
   max(updated_at). It still is not a substitute for stopping the backend, and
   the script says so rather than implying a guarantee.

5. probeSqliteData() treated an unreadable or corrupt file as "no data", which
   would have switched the install to an empty Postgres — the very failure this
   module exists to prevent. It fails closed now and stays on SQLite so the real
   error surfaces.

6. The "you are leaving SQLite data behind" warning was unreachable: setting
   DATABASE_CLIENT skipped the probes, so the branch that produces it never had
   the inputs. Postgres and SQLite are both probed whenever Postgres is the
   engine in play.

Also: the final verification compares row counts for EVERY table rather than
just galleries, and flags only a shortfall — the import legitimately adds an
app_settings row (setSessionsValidAfter) that made the strict equality fail on
a first real run.

Verified against a real PostgreSQL 15 end to end, including: the marker keeps
an install on Postgres after every gallery is deleted; removing the marker and
restoring the file rolls back to SQLite as documented.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 3 — occupancy, bootstrap admin, secrets in /tmp (#1038)

1. Both engine probes judged occupancy by GALLERIES alone. An install whose
   galleries were all deleted, but which still has admins, customers or
   accounting records, was treated as empty: on the SQLite side that meant
   booting the empty Postgres and appearing to lose everything; on the Postgres
   side it meant diverting a live install to a stale SQLite file. Both now look
   across the tables that are empty on a fresh install, matching the migration
   script.

2. The migration ran migrate-schema BEFORE checking the target, and migration
   001 seeds a bootstrap admin when ADMIN_PASSWORD is set (common on legacy
   installs). The occupancy check then saw that admin and refused, pushing the
   operator towards --force against a genuinely empty database. The target is
   read first now.

3. probeSqliteData()'s warning went through the app logger, which writes to
   STDOUT when LOG_TO_CONSOLE=true — and the resolver's stdout is the protocol
   channel wait-for-db.sh captures, so DATABASE_CLIENT could have been set to a
   JSON log line. Diagnostics take an injected sink (stderr in the resolver),
   and the shell now validates the value it captured instead of trusting it.

4. The .picpeak archive holds password hashes, SMTP credentials and API keys in
   plaintext, and was only removed on the fully-successful path — any drift or
   import failure left it in /tmp. Every exit path removes it now.

5. A database-only migration still hauled every business-doc and upload through
   /tmp and back into the same volume. createPicpeak takes includeFiles:false
   for this path; rows move, files stay where they already are.

Verified against a real PostgreSQL 15: a gallery-less install with only an admin
account now stays on SQLite and migrates successfully with ADMIN_PASSWORD set;
the resolver emits exactly one token on stdout with LOG_TO_CONSOLE=true and a
corrupt database; a drift failure leaves Postgres untouched and no archive
behind.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): pin the boot to SQLite while a migration is unfinished (#1038)

Review round 4. A migration that dies after touching Postgres leaves rows
behind — schema creation alone seeds a bootstrap admin when ADMIN_PASSWORD is
set, and a drift or row-count failure can leave a partial load. Since the
occupancy probes were widened in round 3, those rows read as "Postgres is
occupied", so the next restart would switch engines and hide the SQLite data
that is still the database of record.

The script now writes a pin file next to the database BEFORE its first Postgres
write and clears it only on success (after the success marker exists, so no
restart in between can pick the wrong engine). While the pin is present the
resolver stays on SQLite and explains why.

Verified against a real PostgreSQL 15 by reproducing the exact scenario: a
migration failed mid-run with ADMIN_PASSWORD set, leaving one bootstrap admin
in Postgres. With the pin the next boot resolves to sqlite3; with the pin
removed it resolves to pg — the failure this closes. The subsequent successful
re-run clears the pin and the boot moves to Postgres.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 5 — occupancy, path drift, retry, host default (#1038)

1. A seeded bootstrap admin counted as "Postgres is occupied". core/001_init.js
   inserts one whenever ADMIN_PASSWORD is set, so a Postgres that was
   initialised once and never used would have beaten a SQLite file full of real
   galleries — the exact failure the guard exists to prevent, reintroduced by
   widening the probe in round 3. The two sides are deliberately asymmetric now:
   the SQLite probe counts any user data (err towards keeping data visible),
   the Postgres probe ignores rows that schema creation seeds (err towards
   requiring proof of real use).

2. The guard resolved DATABASE_PATH with its own logic while knexfile trimmed
   whitespace and collapsed the legacy duplicated-backend form. A path either
   engine normalised differently meant probing a file nobody uses, concluding
   there was no SQLite data, and booting an empty Postgres. The resolution now
   lives in one module both require.

3. Re-running after a partial migration — the documented recovery — was refused
   unless the operator passed the destructive-sounding --force, because the
   half-written rows read as target data. An unfinished run of this same script
   is now recognised as a safe retry.

4. wait-for-db.sh verified readiness against its own default host (`postgres`)
   while knexfile's production block defaults to `db`. With NODE_ENV now baked
   in, a bare `docker run` without DB_HOST would have passed the readiness check
   against one host and then dialled another. The entrypoint exports the exact
   connection it verified. Compose sets DB_HOST explicitly and is unaffected.

Verified: a Postgres holding only a seeded admin now loses to real SQLite data;
a DATABASE_PATH with surrounding whitespace resolves to the identical file in
both knexfile and the guard.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 6 — explicit-client bypasses, retry scope, cleanup (#1038)

1. An explicit DATABASE_CLIENT bypassed the unfinished-migration pin, because
   decideBootEngine honoured it first. docker-compose sets DATABASE_CLIENT=pg,
   so a failed migration would have restarted on a half-written Postgres on
   exactly the deployments that pin it. Worse in the other direction: with
   DATABASE_CLIENT=sqlite3, a SUCCESSFUL migration renames the source file, so
   the next start created a NEW, empty SQLite database and served that. The pin
   now outranks explicit pg (clearing the marker is the override), explicit
   sqlite3 is left alone since it already points at the data, and the migration
   refuses up front when the deployment pins anything other than pg.

2. The retry allowance was bound to the SQLite file, not to the target. An
   operator who repointed DB_HOST/DB_NAME between attempts could have replaced
   an unrelated populated database without --force. The pin records the target
   and the allowance only applies when it matches.

3. The printed rollback did not roll back: with data on both sides and no
   marker, the resolver still selects Postgres. It now spells out all three
   steps, including DATABASE_CLIENT=sqlite3.

4. A failure inside createPicpeak left a partial archive — plaintext hashes and
   credentials — in the caller-supplied temp dir, which that service
   deliberately does not clean. The export phase removes it on error.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 7 — pin bypass on direct start, real admins (#1038)

1. server.js only ran the engine resolver when DATABASE_CLIENT was unset, so a
   deployment that both bypasses the entrypoint (Kubernetes `command:`) AND
   pins DATABASE_CLIENT=pg never consulted the migration pin — the round-6 fix
   was unreachable on exactly that path, and a failed migration would have
   served a half-populated Postgres. The resolver now also runs whenever a pin
   file exists.

2. Round 5 excluded admin_users from Postgres occupancy to stop a seeded
   bootstrap admin counting as real data. That over-corrected: an install that
   has completed first-run setup but has no galleries yet has exactly one
   user-created row — an admin — so Postgres looked empty and, with a stale
   SQLite file present, the boot would switch away and the admin's credentials
   and configuration would disappear.

   core/001_init.js seeds must_change_password=true; setupService writes false
   once a human completes setup. The FLAG, not the table, distinguishes them,
   and a legacy NULL counts as a real admin.

Verified against a real PostgreSQL 15: a Postgres holding only the seeded row
loses to real SQLite data, the same Postgres wins once setup is completed, and
a server started directly with DATABASE_CLIENT=pg and a pin present comes up on
SQLite with the warning.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 8 — reset admins, CLI config, JSON nulls (#1038)

1. must_change_password is mutable: resetAdminPassword() re-raises it on REAL
   accounts (userManagementService.js:474). Round 7's discriminator therefore
   read a gallery-less Postgres whose only admin had been reset as an untouched
   bootstrap seed — and with a stale SQLite file present, the boot would have
   switched away and hidden those live credentials. The rule is layered now:
   more than one admin, any admin that has logged in, or must_change_password
   false all count as use. Only core/001_init.js's exact leftovers — one admin,
   never logged in, still flagged — read as a seed.

2. The CLI read process.env directly but never loaded the configuration the
   child phases get through knexfile, so running it directly (or via
   `docker exec`, which does not inherit wait-for-db.sh's exports) failed the
   pre-flight checks even with valid settings in backend/.env or
   /run/secrets/db_password. Both sources are loaded up front now.

3. The migration's target check counted a seeded bootstrap admin as user data
   while probePgData classified the identical row as empty, so migrating into a
   previously-initialised-but-unused Postgres demanded --force. Same rule on
   both sides.

4. Cross-engine JSON handling is simpler and no longer lossy. SQLite keeps json
   columns as TEXT holding valid JSON and Postgres accepts JSON text directly,
   so the correct action is to pass them through untouched. Round 1 parsed then
   re-serialised them to undo a double-stringify; that round-tripped the JSON
   literal `null` into SQL NULL, changing data and breaking NOT NULL json
   columns. Not serialising at all fixes both.

Verified against a real PostgreSQL 15: a migrated install now carries
json_typeof = null for a JSON null, object for a nested object, and boolean for
a boolean — matching a native install exactly.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): close review round 9 — probe error classes, marker ordering (#1038)

1. probePgData() answered every failure with "Postgres has data". That is right
   for an unreachable server — the app cannot run on it either way, and
   diverting a healthy pg install to a stale SQLite file over a transient blip
   would be worse — but wrong for a server that answers and then fails the
   query, which is what a half-built or damaged schema looks like. That is not
   evidence of data, and reporting it as such booted the empty Postgres and hid
   a populated SQLite file: the exact failure this guard exists to prevent.

   Reachability is now established with SELECT 1 first, so the two cases get
   opposite answers: unreachable → leave the configured engine alone;
   reachable-but-uninspectable → unproven, and the SQLite side wins if it
   actually holds data.

2. The success marker was written after the SQLite file was renamed away. A
   failure in between — a full disk — left the source retired with no marker:
   the next attempt reported "No SQLite database", the in-progress pin stayed,
   and the operator never saw the rollback path. The marker is written first
   and updated with the retired filename once the rename succeeds, so a failure
   at any point leaves everything recoverable.

Verified against a real PostgreSQL 15: a reachable database whose admin_users
table lacks the probed column now resolves to sqlite3 rather than hiding the
data, while an unreachable host still resolves to pg.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): don't fail the migration on empty SQLite-only tables (#1038)

Review round 10. The final verification flagged every source table missing from
Postgres, regardless of whether it held rows — and SQLite-only tables do exist:
initializeDatabase() creates an `events_new` scratch table and, when its legacy
column copy throws, the catch swallows the error and leaves the empty table
behind (db.js:236). The importer correctly skips tables Postgres does not have,
so verification then reported a mismatch AFTER the data had already landed,
exited 1, and left the install pinned to SQLite with no way to finish.

An absent target table only matters if the source actually had rows. Empty ones
are now listed and skipped.

Reproduced both ways against a real PostgreSQL 15 with an events_new table
present: without the fix the run ends in "ROW COUNTS DO NOT MATCH" and leaves
the in-progress pin; with it, the table is reported as skipped, the migration
completes and the pin is released.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): a completed migration overrides an implicit SQLite config (#1038)

Review round 11. The migration allowed the one configuration it should have
worried about most: DATABASE_CLIENT unset AND NODE_ENV not "production", which
resolves to the development block — i.e. sqlite3. That is precisely the state
the affected installs are in, since it is why they ended up on SQLite at all,
so an operator can easily run the migration before fixing it. The script then
renames the source database away, and the next start resolved to the implicit
sqlite3, created a NEW empty database and served it — after reporting success.

The success marker now overrides an IMPLICITLY resolved sqlite3 when Postgres
settings are present, because the marker is durable proof of where the data
actually went. An explicit DATABASE_CLIENT=sqlite3 still wins: that is the
documented rollback.

The script says something rather than refusing — refusing would block exactly
the population this exists for.

Reproduced with NODE_ENV and DATABASE_CLIENT both empty, against a real
PostgreSQL 15: the migration completes, the source is renamed away, and the
next boot resolves to pg with the data intact. Before this it resolved to
sqlite3 and would have served an empty database.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* refactor(db): drop the dead reachability flag in probePgData (#1038)

github-code-quality flagged `if (reachable)` as always true, and it is right:
the unreachable branch returns, so everything below it runs only when the probe
connected. The variable and the conditional were leftovers from a first draft
that used a single catch for both failure classes.

No behaviour change — the two error paths still return opposite answers.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): refuse to choose when both databases hold data (#1038)

Review round 12.

1. An install that ran on PostgreSQL, lost NODE_ENV/DATABASE_CLIENT, and kept
   working on SQLite has REAL data on both sides: old rows in Postgres, newer
   ones in SQLite. The stranded-data rule only protected SQLite when Postgres
   was empty, so pulling this fix would have booted Postgres and hidden every
   gallery created since the switch — the exact failure this PR exists to
   prevent, in a variant I had not considered.

   A completed migration leaves a marker saying which side is current. Without
   one, two populated databases are a conflict: the boot stops and prints both
   targets, the two DATABASE_CLIENT values that resolve it, and the migration
   command that merges them. This is the only deliberate refusal in the change —
   guessing here would hide data AND split subsequent writes across two
   databases.

2. probePgData was handed knexConfig.connection even when knexfile had resolved
   to SQLite (a completed migration whose environment still says sqlite3), so
   node-postgres dialled its own localhost defaults instead of DB_HOST/DB_NAME —
   false "unreachable" diagnostics and a needless delay on every boot. The probe
   target is now built from the environment when the config is not pg.

The conflict is honoured by all three entry points: the resolver exits 3 with an
empty stdout, wait-for-db.sh stops the container, and server.js refuses to start.

Two existing tests asserted that Postgres wins when both sides hold data. They
encoded the pre-conflict assumption and described a state that cannot occur
after a real migration (which always leaves a marker); both now pass the marker.

Found while testing: the resolver's logger shim had no .error, so the conflict
path threw, was swallowed by the fallback, and silently chose Postgres — the
precise outcome this refuses to make. The shim is complete now.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): symmetric bootstrap rule, one resolved Postgres target (#1038)

Review round 13. Both findings are consequences of earlier rounds.

1. The conflict rule added in round 12 counted an untouched SQLite bootstrap
   admin as data. core/001_init.js seeds one whenever ADMIN_PASSWORD is set —
   including into the accidental SQLite database — so a healthy Postgres install
   that had ever started once without NODE_ENV would have had a seeded-only
   SQLite file beside it, been declared a both-populated conflict, and REFUSED
   TO BOOT. The bootstrap discrimination is applied on both sides now; a
   setup-completed or logged-in admin still counts as real use on either.

2. The CLI's child phases inherited whichever knexfile block NODE_ENV selected.
   The development block defaults Postgres to localhost/postgres/photo_sharing,
   production to db/picpeak/picpeak — and this script is explicitly meant to run
   with NODE_ENV unset. With DB_USER/DB_NAME left to defaults it would therefore
   have migrated into `photo_sharing`, after which following the script's own
   advice to set NODE_ENV=production pointed the app at an empty `picpeak`.

   The target is resolved once, with production defaults, and passed explicitly
   to every phase — so the block knexfile happens to pick can no longer decide
   which database the data lands in. The pin and success marker record that same
   resolved identity.

Verified against a real PostgreSQL 15: a live Postgres beside a seeded-only
SQLite file now boots pg rather than refusing, flipping that admin to
setup-completed restores the conflict, and a migration records
localhost:7102/picpeak_r13b as its target rather than a defaulted guess.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): one Postgres identity everywhere; protect the credentials file (#1038)

Review round 14. Three of the six findings were the same defect as round 13's,
surfacing through paths that fix did not cover: the connection used to PROBE or
MIGRATE could differ from the one the application then OPENS, because
knexfile's development block points Postgres at localhost/postgres/photo_sharing
while production uses db/picpeak/picpeak.

1. server.js exported only DATABASE_CLIENT=pg after the resolver decided, so
   knexfile filled in host/user/database from whichever block NODE_ENV selected.
   With SQLite already retired by a migration, that meant opening an empty
   database. The whole connection is pinned now.

2. Two defaults existed for DB_HOST: wait-for-db.sh resolves and exports
   `postgres`, knexfile's production block says `db`. Since the entrypoint
   exports its value, `postgres` is what a running container actually uses — so
   a `docker exec` migration, which inherits neither, has to agree with that,
   not with the default that is only reached when the entrypoint did not run.

3. The migration's Postgres phases inherited an unset NODE_ENV and therefore the
   development block, which ignores DB_SSL entirely — a managed Postgres
   requiring TLS could never be migrated into. The phases run with production
   semantics now.

4. core/001_init.js writes data/ADMIN_CREDENTIALS.txt, and that data directory
   belongs to the SOURCE install. Bootstrapping the Postgres schema replaced the
   operator's real credentials file with ones for a temporary admin the import
   immediately discards. The file is preserved across the phase, including when
   it fails.

5. The boot line described knexConfig, so an install redirected to Postgres by a
   migration marker still logged "Database engine: sqlite (...)", contradicting
   the warning printed one line earlier.

6. On a both-populated conflict resolveBootEngine returns client:null, and both
   migration runners told the operator their data was in "null" and to set
   DATABASE_CLIENT=null. They now present the two real choices.

Verified against a real PostgreSQL 15: a migrated install started directly with
NODE_ENV unset now logs `postgres (localhost:7102/picpeak_r14)` and opens it,
where before it would have gone to the development block's photo_sharing.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* refactor(db): resolve the PostgreSQL target in exactly one place (#1038)

Rounds 13 and 14 both traced back to the same thing, each time through a caller
the previous fix had not covered: three different defaults existed for the same
connection.

  knexfile development : localhost / postgres / photo_sharing
  knexfile production  : db        / picpeak  / picpeak
  wait-for-db.sh       : postgres  / picpeak  / picpeak   (and it EXPORTS them)

So a process that probed or migrated against one could hand over to a process
that opened another. Patching each caller was not converging — the guard, then
the CLI's child phases, then server.js — so this deletes the divergence instead.

`src/utils/pgConnection.js` now owns the resolution and knexfile's development
and production blocks both derive from it, as does the engine guard. Same shape
as the earlier sqlitePath.js extraction, for the same reason.

The database NAME is what made this dangerous: a wrong host or user fails
loudly at connect time, while a wrong name connects fine and presents an empty
installation.

BEHAVIOUR CHANGE: with DATABASE_CLIENT=pg and no DB_* variables, a
non-production environment now resolves to postgres/picpeak/picpeak instead of
localhost/postgres/photo_sharing. Deployments are unaffected — compose sets
these explicitly and wait-for-db.sh exports them — but a local machine running
Postgres bare now needs DB_HOST=localhost DB_USER=postgres DB_NAME=photo_sharing
(or DATABASE_CLIENT=sqlite3, which is what backend/.env already uses). The
failure mode of getting this wrong is a refused connection, not a silently empty
database.

Side effect worth having: DB_SSL is now honoured whatever NODE_ENV says, so the
managed-Postgres case is fixed at the root rather than by forcing production
semantics onto the migration's child phases.

The test block keeps its own photo_sharing_test default — isolation is the point
there.

Verified: every block plus the guard resolve identically from the same
environment; explicit DB_* still wins; production's pool tuning is preserved;
and a full SQLite → PostgreSQL migration with NODE_ENV unset lands in the right
database with JSON shapes intact.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): two more components that guessed the database instead of asking (#1038)

Both found while sweeping for copies of the connection defaults. Checked in
detail first — one of my suspicions about them was wrong.

scripts/set-admin-password.js hand-rolled its own knex config while all four
sibling scripts (reset-admin-password, create-admin, show-admin-credentials,
reset-admin-mfa) use the application's connection. Two consequences:

  - it read DB_CLIENT, a variable nothing else in this codebase sets, so it
    defaulted to Postgres and could not work on a SQLite install at all;
  - it defaulted to database `picpeak_dev`, a name no other component uses.

It now uses `require('../src/database/db')` like its siblings, so it follows
whatever engine the install actually runs on. Timestamps are written as ISO
strings because it reaches SQLite now, where raw Date objects are the documented
landmine.

NOT changed: the script's "all existing sessions have been invalidated" notice
is accurate — auth.js compares token iat against password_changed_at — and it
deliberately leaves must_change_password alone, which is right for an operator
choosing a password rather than being issued one.

routes/adminSystem.js re-derived three things the live connection already knows,
and each could disagree with it:

  - the engine, from DATABASE_CLIENT || 'sqlite3' — so a Postgres install
    without an explicit DATABASE_CLIENT took the SQLite branch;
  - the Postgres database, from DB_NAME || 'picpeak';
  - the SQLite file, from a hardcoded ../../data/photo_sharing.db that ignored
    DATABASE_PATH entirely.

All three now come from db.client.config, with pg_database_size(current_database()).

Verified: set-admin-password works on SQLite (new hash verifies, old rejected)
and still on PostgreSQL; and on a SQLite install with a custom DATABASE_PATH the
size logic reports the real database (1,748,992 bytes) where the old code
reported a different file entirely (1,851,392) — or 0 where that path does not
exist.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

* fix(db): bind the migration marker to its target; fix a phantom table (#1038)

Review round 15.

1. The marker records `host:port/database`, but only its EXISTENCE was checked.
   Repoint DB_NAME or DB_HOST at a different, empty PostgreSQL after migrating
   and the marker would vouch for that one too — booting it, presenting an empty
   installation, and suppressing the SQLite fallback while the real data sits in
   the recorded target and the renamed rollback copy. The marker is compared
   against the current connection now, and a mismatch stops the boot with both
   targets named and the two ways out.

2. `incoming_invoices` is not a table — supplier documents live in
   `inbound_documents` (core migration 124). Both occupancy lists skip tables
   that do not exist, so those records were silently not protecting anything:
   an install whose only remaining data was inbound documents could be switched
   away from, or overwritten without --force. Verified every other name in the
   lists against the live schema at the same time.

Verified: a marker naming picpeak_original with picpeak_mk configured refuses
with exit 3 and prints both; making them agree boots pg.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:51:20 +02:00
Paul Nothaft de459c701f fix(feedback): persist guest feedback settings, unshadow the guest route (#1030) (#1032)
Enabling Guest Feedback on an event could silently do nothing.

1. `updateEventFeedbackSettings` spread the request body straight into the
   knex UPDATE. The admin event form posts its whole client-side state,
   including three keys that were never columns on event_feedback_settings
   (`enable_rate_limiting`, `rate_limit_window_minutes`,
   `rate_limit_max_requests`), so the write threw and the route answered 500.
   Writable columns are now whitelisted; identity columns and timestamps stay
   server-managed.

2. EventDetailsPage swallowed that 500 in a bare `catch {}` ("Error already
   handled by mutation" — it is a different request), so the admin was left
   looking at "Event updated successfully" while the toggle never persisted.
   The error is surfaced now and the settings query is invalidated on success.

3. gallery.js declared a duplicate `GET /:slug/feedback-settings`. server.js
   mounts galleryRoutes before galleryFeedback, so it shadowed the real
   handler and dropped the per-guest caps (#655) from the guest payload — the
   gallery could never render the favorite/like limits or their counters.

Timestamps are written as ISO strings so they round-trip on both engines.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:51:16 +02:00
Paul Nothaft 8b6cd3c74f fix(gallery): coerce SQLite 0/1 booleans in the guest surface (#1028) (#1037)
SQLite stores booleans as 0/1, Postgres as true/false. The guest gallery
compared strictly against `true`/`false`, so every flag read backwards on
SQLite installs:

    allow_downloads:    0 !== false → true   (header Download button shown
                                              with downloads disabled)
    allow_user_uploads: 1 === true  → false  (upload button hidden with
                                              uploads enabled)

Worse, all five download guards used `allow_downloads === false`, which never
fires against a stored 0 — so on SQLite "Allow photo downloads = off" was
inert end to end: single photo, download-all, download-selected, download-jobs
and the job-status poll all kept serving, as did the secure-images download
route. Per-category blocking (#640) was ignored for the same reason, the
protection toggles (right-click, devtools, canvas, watermark) reported false
while enabled, overlay_protection was stuck on, and show_feedback_to_guests
leaked feedback with the setting off.

The /info endpoint was already correct — it checks 0/'0' explicitly. The two
payloads had simply drifted. Everything now goes through parseBooleanInput
(utils/parsers.js), which normalises both engines and takes a per-column
default so legacy NULL rows keep their documented behaviour.

Tests run on the SQLite harness, so they assert the real engine values. Every
one of them fails on the unfixed code — the payload assertions return the
inverted value, and the guard assertions never get their 403 (the request
proceeds to serve instead).

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:51:12 +02:00
Paul Nothaft fb3d0b08b2 fix(events): make event_date/expires_at nullable on SQLite (#1029) (#1036)
Clearing a gallery's expiration failed on every SQLite install with

    SQLITE_CONSTRAINT: NOT NULL constraint failed: events.expires_at

surfacing in the admin UI as "Failed to update event".

Migration 061 added the event_require_event_date / event_require_expiration
settings and dropped the NOT NULL on both columns — but only for Postgres. It
skipped SQLite on the premise that "SQLite doesn't enforce NOT NULL as
strictly", which is untrue, so "never expires" was never reachable there. The
#426 work that allows clearing the expiration on edit therefore never worked
on SQLite either.

Migration 174 finishes 061 for SQLite. Knex implements .alter() on SQLite by
recreating the table; migration 073 already does that on `events`, so the path
is well-trodden. Postgres is skipped — it was handled in 061 and .alter() there
would needlessly rewrite a column that is already correct.

The test asserts against the real engine (the Jest harness runs SQLite) and
reproduces the reporter's exact error without the migration.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:51:09 +02:00
Paul Nothaft 945e63ae86 chore: ignore all of backend/storage on stable (#1033)
main already ignores `backend/storage/` wholesale; stable only ignored
`backend/storage/business-docs/`. A dev instance writes event photos,
thumbnails and previews into backend/storage/, so `git add -A` on this
branch sweeps 17 runtime artifacts into the commit.

Claude-Session: https://claude.ai/code/session_0168gubtwYYacJv8weAjy8DM

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:51:04 +02:00
Paul Nothaft 93d4ae68f4 chore(stable): release 3.45.15 (#1017)
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-08-10 20:52:00 +02:00
Paul Nothaft 2bdb1204fe fix(slideshow): stop "no crop" fit letterboxing a pre-cropped frame (stable) (#1015) (#1019)
Backport of #1018 to the curated channel; the reporter on #1015 is running v3.45.14.

The slideshow resolved its image as preview_url || hero_url || url. preview_url is only emitted when lightbox_preview_enabled is on (default false), so a default install fell through to hero_url — the 1920x1080 fit:'cover' centre crop built for gallery header banners. object-fit: contain then letterboxed an already-cropped 16:9 frame.

Emits slideshow_url (same aspect-preserved preview tier) unconditionally for image photos; the show prefers it and never falls back to hero_url. preview_url stays gated so the lightbox opt-in is unchanged.
2026-08-10 13:32:24 +02:00
Paul Nothaft cee0a380a6 fix(deps): bump nanoid and js-yaml out of two HIGH advisories (stable) (#1014)
Backport of #1013 to the curated channel. Both are production dependencies of the backend image (npm ci --omit=dev):

- nanoid 3.3.16 -> 3.3.18 (CVE-2026-67213, infinite loop in customAlphabet)
- js-yaml 4.3.0 -> 4.3.1 (GHSA-5p4m-2wfm-xmqj, quadratic CPU in !!omap resolution)

Stable reported no open alerts only because its last Trivy scan ran on 2026-08-04 with v3.45.14, before either advisory was published — the vulnerable versions were present in the lockfile regardless.

Lockfile-only; the existing ^ ranges already permitted both fixes.
2026-08-10 11:00:03 +02:00
Paul Nothaft c01d8d8d2e chore(stable): release 3.45.14 (#990)
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-08-04 21:26:28 +02:00
Paul Nothaft bf9bd76278 fix(security): vet the destination project when linking a deal (stable) (#992)
Backport of #991. stable carried the identical code path and the same missing
guards.

A scoped admin could point a quote or contract at a project they do not own —
the quote/contract create+update paths pass a body-supplied projectId with no
ownership check, and linkDealToProject's lineage guard is skipped when the deal
has no event yet. On an ownerless project this escalated to a read once the
quote converted to an event.

Vetted at the service choke point, ahead of both the null-deal early return and
the customer check. 404 PROJECT_NOT_FOUND throughout. super_admin unaffected.
2026-08-04 16:36:28 +02:00
Paul Nothaft 0fe5792a7d fix(deps): bump ip-address, brace-expansion and postcss for open CVEs (stable) (#988)
Backport of #987. stable carried the same vulnerable versions.

  brace-expansion  5.0.8  -> 5.0.9   CVE-2026-69152 (high)
  ip-address       10.2.0 -> 10.4.0  CVE-2026-69192 (high), CVE-2026-54272,
                                     CVE-2026-69198 (medium) — SSRF and
                                     trust-boundary bypasses
  postcss          8.5.18 -> 8.5.23  CVE-2026-69153 (medium)

Lockfile holds exactly one entry per package, all at or above the fixed
version; the image installs via npm ci --omit=dev.
2026-08-04 14:36:10 +02:00
Paul Nothaft 3f7364be8e chore(stable): release 3.45.13 (#972)
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-08-03 21:26:03 +02:00
Paul Nothaft 2d0e6ab2dc fix(projects): stop the cockpit offering email controls the API rejects (stable) (#977)
Closes #969 on stable. Backport of #976.

The cockpit's email feed rendered preview/resend/cancel/retry/send-now for every mail regardless of role or permission, producing 404s (CRM document mail has no event_id; project ownership does not imply event ownership) and 403s (preview needs events.view, the write actions need email.send).

getProjectOverview now stamps each email with an authoritative canAct, mirroring filterOwnedEventIds; created_by is selected only for that check and stripped before the response. A missing canAct reads as false.
2026-08-03 14:49:04 +02:00
Paul Nothaft cc49f6997a fix(auth): fail closed when the adminAuth roles join errors (stable) (#975)
Closes #968 on stable. Backport of #974.

The roles-join fallback in adminAuth fabricated role_name='super_admin' on ANY database error, so a transient fault silently granted super_admin for its duration. Gate it on isMissingRolesSchema(), moved to utils/dbErrors.js and shared with apiTokenAuth, with the predicate tightened to trust SQLSTATE 42P01/42703 on Postgres and exact driver phrasing on SQLite.
2026-08-03 14:48:33 +02:00
Paul Nothaft fecc18cbc8 fix(security): enforce project ownership on project + project-email routes (stable) (#966)
* fix(security): enforce project ownership (GHSA-wrg5, GHSA-93x4)

Project routes authorized on generic events.view / events.edit with NO
ownership check, so an editor-like admin could enumerate, read, update and
aggregate projects belonging to other admins' events. The project email
endpoints keyed on an email_queue id alone — any admin with events.view /
email.send could preview, resend, cancel or retry ANY queued mail by walking
ids.

The earlier 'needs a migration, deferred' assessment was wrong in one
direction and right in another: ownership IS derivable transitively via
events.project_id -> events.created_by, but only for projects that already
have a linked event. A brand-new EMPTY project has no derivable owner, which
is exactly where the create -> attach flow starts. So migration 167 adds
projects.created_by (backfilled from the single linked event owner, skipping
ambiguous multi-owner projects) and createProject finally persists the adminId
it was already being passed.

- ownedProjectIds(): union of the stored owner and the transitive path, so
  pre-167 rows and new empty projects both resolve. Reads created_by
  defensively so an instance that hasn't run 167 falls back to the transitive
  rule instead of throwing.
- requireProjectOwnership on detail/update/attach-event/attach-quote/
  attach-contract/overview; list filtered by an id allowlist (empty array
  means 'owns nothing' and must return no rows, hence null-vs-[] care).
- POST /:id/events also validates the INCOMING eventId — owning the project
  is not enough, or an editor could pull a foreign event in and read its
  rolled-up documents via /:id/overview.
- Queued-email routes scoped via email_queue.event_id. CRM document mail has
  event_id NULL and no ownable parent here, so a scoped caller is denied
  rather than guessed into access. 404 (not 403) so it isn't an id oracle.

Note: adminEmail.js:315/332 let any email.view/edit admin archive or delete
any email_queue row — the same class, pre-existing and outside these two
advisories. Left untouched and reported rather than silently widened.

* fix(security): codex round 2 — make the stored project owner authoritative (GHSA-wrg5)

The first predicate union'd 'any linked event I can see' with the stored
owner, which opened two holes:

- A project owned by admin B containing ONE legacy ownerless event became
  readable by every admin — and /:id/overview aggregates B's other events,
  invoices and emails, so a single legacy event exposed the whole project.
- Migration 167 deliberately leaves multi-owner (ambiguous) projects NULL
  rather than guessing an owner. A NULL owner was then treated as
  'everyone's', so exactly those mixed projects became globally accessible.

Now: the stored created_by wins outright, and a project without a usable
stored owner only derives access when EVERY linked event is accessible (and at
least one exists). A created_by pointing at a hard-deleted admin degrades to
'no usable owner' so the project falls back to its events instead of being
locked away — no ON DELETE SET NULL migration needed. A project with neither a
usable owner nor linked events stays super_admin-only: failing closed beats
failing open, and a super_admin can reassign it.

Also returns a knex SUBQUERY rather than a materialised id list, so a large
project count can't hit the driver's bind-parameter limit.

* fix(security): codex round 3 — enforce deal-lineage ownership on project attach (GHSA-wrg5)

requireProjectOwnership vets only the DESTINATION project, while attaching a
quote or contract cascades through linkDealToProject — which re-points every
event the deal produced into that project. An editor could therefore create an
empty project of their own, attach another admin's quote, and pull that admin's
events (plus the invoices, emails and gallery that roll up with them) into a
project they own and can read via /:id/overview. The single-customer guard did
not stand in the way: an unassigned project ADOPTS the deal's customer rather
than rejecting it.

linkDealToProject now refuses to move lineage events the actor cannot own, and
assignDocument cascades BEFORE stamping the document so a refused attach leaves
nothing half-applied (the old order committed the foreign document into the
caller's project and only then declined the cascade). The quote/contract
create+update paths, which reach the same cascade with an arbitrary project_id,
thread their adminId through as well; isSuperAdmin() resolves the role for them
and fails closed when it cannot.

Events are the only ownership signal a deal carries — quotes and contracts have
no created_by in this schema — so a lineage that produced no event still cannot
be attributed. That is a property of the CRM model, noted in the code.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit 688e318850db1b5f4ea2a4ae3c0fcf0fc137620d)

* docs(security): drop the stale ownership JSDoc left by the rebase (GHSA-wrg5)

Rebasing onto stable (which had gained scopeEventsQuery from #963) replayed the
round-1 doc block above round-2's replacement, leaving a comment that describes
the ORIGINAL union rule — "a project is the caller's when … it has at least one
linked event they own" — directly above the code that deliberately no longer
does that. That union is the hole round 2 closed; a comment asserting it is
worse than none.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:24:41 +02:00
Paul Nothaft 7f27e6771f fix(security): escape brand tokens, block tracker redirects, trim logo diagnostic (stable) (#967)
* fix(security): escape brand tokens, block tracker redirects, trim logo diagnostic (GHSA-j347, mw76, 29vm)

GHSA-j347 — buildCachedPayload sanitizes the operator's HTML and THEN runs
applyBrandTokens over the result with a plain String.replace, so any markup in
a token value reached the public origin unfiltered. The default templates
interpolate tokens into text AND into quoted attributes
(<img src="{{brand_logo_url}}" alt="{{company_name}} logo">,
href="mailto:{{support_email}}"), so a value could close the attribute and
inject. Token values are now HTML-escaped on substitution, mirroring
galleryOgService's escapeHtml. sanitizeBrandUrl's case-sensitive literal
'javascript:' check (which 'JavaScript:' walked straight past) is replaced by
an http/https scheme allowlist; relative logo paths are unaffected.

Writer is settings.edit (super_admin only) and the CSP blocks inline script,
so this is defence-in-depth — but sanitize-then-substitute is a real ordering
bug regardless.

GHSA-mw76 — the SSRF decline STANDS: self-hosted operators legitimately point
analytics at private addresses, so connection-time IP blocking would break real
deployments. Fixed only the narrow leak: undici strips
Authorization/Cookie/Proxy-Authorization/Host across a cross-origin redirect,
but umamiAdapter sends a CUSTOM x-umami-api-key header, which would be replayed
verbatim to the redirect target. Both adapters now use redirect: 'error'.

GHSA-29vm — the logo diagnostic echoed absolute storage roots, process.cwd()
and absolute candidate paths. It now reports candidates relative to
<STORAGE>/<CWD_STORAGE>, which answers the same 'which candidate existed'
question. It also still advertised the raw-absolute candidate that GHSA-c7x5
removed from resolveLogoFile, so it was misreporting what the resolver tries —
aligned with the real candidate list.

publicSiteService.test.js expectation updated: an '&' in a company name is now
emitted as '&amp;'. Renders identically; the raw payload string differs.

* fix(security): codex round 2 — stop the remaining logo-path disclosure, mirror the resolver (GHSA-29vm)

- sources[].value was still echoed verbatim. branding_logo_path is stored
  ABSOLUTE by multer, so relativising only resolvedTo and the candidate paths
  left the filesystem layout going out anyway. It is now relativised too.
- Round 1 dropped the raw-absolute candidate on the grounds that GHSA-c7x5
  removed it from resolveLogoFile — but the c7x5 follow-up RE-ADDED it (kept,
  subject to the containment filter, so a legitimate multer path still
  resolves). The diagnostic therefore reported every candidate as missing for
  a contained absolute logo while resolvedTo named the file. It now mirrors the
  resolver, containment filter included.

One deliberate cosmetic divergence, commented in place: for an absolute value
the resolver also tries path.join(root, value-minus-leading-slash), which can
never exist and would re-embed the absolute path this endpoint must stop
echoing. Omitted; every candidate that can actually match is still shown.

* fix(security): codex round 3 — mirror the resolver for root-relative logo paths (GHSA-29vm)

The logo diagnostic skipped the `<STORAGE>/<value>` candidates whenever
path.isAbsolute(value) was true. That test cannot distinguish a multer disk
path from a root-relative URL such as `/custom/logo.png`, and for the URL form
resolveLogoFile.generateCandidates() does try `<STORAGE>/custom/logo.png` and
can resolve it — so the endpoint reported "no source candidate exists" about a
logo that renders fine, and collapsed the configured value to its basename.

The stripped joins are now built unconditionally, exactly as the resolver does.
Disclosure stays closed: every candidate still passes the containment filter and
redact() rewrites survivors to `<STORAGE>/…`, never an absolute host path.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit 093480a753ff3d4b6ed48dd9f1108f975c8e0d47)

* fix(security): gate the logo stripped-joins on containment, not isAbsolute (GHSA-29vm)

The previous commit dropped the isAbsolute() gate entirely and regressed
logoDiagnostic's own disclosure assertion: for a genuine multer disk path,
path.join(root, value-minus-leading-slash) yields
`<STORAGE>/tmp/…/storage/custom/logo.png`, and redact() only rewrites the
LEADING root — so the inner absolute path went straight back into the payload.

The right discriminator is not "is this absolute" (which cannot separate a disk
path from a root-relative URL) but "does the value already resolve inside a
storage root". If it does, it is a real disk path, the raw candidate already
covers it, and the stripped join is the double-prefixed junk that can never
exist. If it does not — the `/custom/logo.png` URL form — the stripped join is
exactly what resolveLogoFile resolves, and is shown.

Covered by a new case asserting both halves: the candidate appears for the URL
form, and the payload still contains neither the storage root nor cwd.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit c6b95d3cd1cb28e5c2828d29d4d63fadad981dcf)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:18:55 +02:00
Paul Nothaft 4e99897313 fix(security): enforce event ownership on the v1 API surface (GHSA-9697) (stable) (#963)
* fix(security): enforce event ownership on the v1 API surface (GHSA-9697)

Migration 081 documents the intent — 'the token's effective permissions are
the intersection of the user's role permissions and the token's own scope
flags' — but it was never implemented.

- apiTokenAuth selected only id/username/email/role_id, so req.admin.roleName
  was undefined. Every ownership helper keys on roleName, so the v1 surface
  could not tell a super_admin from a demoted viewer. Now joins roles and
  emits the same req.admin shape adminAuth does, including the
  roles-table-missing upgrade fallback.
- No v1 route applied any ownership predicate: GET /events listed every event
  on the instance, and GET /events/:id/share-link returned ANY event's
  share_token — the gallery access credential, same class as GHSA-rh8r.
  List is now scoped via a new scopeEventsQuery helper; the three :id routes
  (detail, photo upload, share-link) use the existing requireEventOwnership.

Not a breaking change: tokens are minted by super_admins, who bypass
ownership. It closes the case where a token's owner is later demoted —
userManagementService never touches api_tokens, so the token outlived the
demotion with full read of every gallery's share token.

events.category.test.js stubbed apiTokenAuth without roleName; giving the
stub super_admin keeps requireEventOwnership from issuing a DB query and
desyncing that suite's sequenced dbMock.

* fix(security): codex round 2 — intersect v1 token scopes with role permissions (GHSA-9697)

Ownership scoping alone left half the documented control missing. Migration
081 defines a token's effective permissions as the INTERSECTION of the owner's
role permissions and the token's scope flags; requireApiScope only ever checked
the scope half. A token minted while its owner was super_admin therefore kept
write access after the owner was demoted to viewer — userManagementService
never touches api_tokens, so the token outlives the demotion, and ownership
scoping does not help because the demoted owner still owns their events.

Adds requirePermission to all six v1 routes (events.create on create,
events.view on the reads, photos.upload on upload). It keys on req.admin.id,
which apiTokenAuth already populates.

The two existing v1 suites mock the database, so a real permission lookup
500s — they now mock the permissions middleware as pass-through, matching how
they already mock apiTokenAuth. Those suites cover route logic; the
intersection is pinned by the new v1TokenPermissions suite.

* fix(security): codex round 3 — fail closed on the roles-join fallback (GHSA-9697)

The round-2 fix loaded the token owner's role so the v1 ownership checks could
tell a super_admin from a demoted viewer, and mirrored adminAuth's
roles-table-missing fallback. That fallback assigns role_name = 'super_admin',
and the catch around it was unconditional — so ANY failure of the joined query
(connection reset, deadlock, statement timeout) elevated the token owner to
super_admin as long as the simpler fallback query then succeeded. A restricted
owner could ride that into listing, reading and share-tokening every event on
the instance, which is the exact hole GHSA-9697 closes.

The fallback is now reached only for an error that genuinely names a missing
roles table/column (PG 42P01/42703 or the SQLite/MySQL wording); anything else
propagates to the 500 handler.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit 53d1e5d1b3148a7f4067308b08fcdf8ddab0a39f)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:18:52 +02:00
Paul Nothaft ccab9024d4 fix(security): bound inbound-mail resources, redact secrets from logs (stable) (#965)
* fix(security): bound inbound-mail resources, redact secrets from logs (GHSA-2qf9, pgmp, r794)

GHSA-2qf9 — emailIntakeService downloaded, parsed and persisted every message
with no size, attachment-count or attachment-byte limit, reachable
unauthenticated by anyone who can email the operator's mailbox:
- fetch the envelope with `size` (same cheap pass) and refuse an oversized
  message BEFORE downloading its source;
- cap attachment count and cumulative attachment bytes;
- limits env-overridable, defaults generous for real supplier invoices.

The teeth were in the dedup key. received_emails.message_id is varchar(512)
UNIQUE, and the failure path wrote `err-<uid>-<Date.now()>`, which can never
match the envelope-derived messageId the dedup pass compares against — so an
oversized (or overlong-Message-ID) mail was re-downloaded every poll forever,
and an OOM-kill/restart just resumed the loop. Size-skips are now recorded
under the REAL message id, and overlong ids collapse to a stable sha256 key
that always fits the column.

GHSA-pgmp / r794 — new sanitizeForLog() util (key-name deny-set, recursive,
cycle-safe) applied to the three request-body log sites in adminEvents/crud.js,
plus sanitizeValidationErrors() because express-validator's errors.array()
embeds the SUBMITTED value per field — a rejected plaintext password was still
logged. Scope is wider than filed: the update path also logged
client_password_hash and a LIVE client_share_token bearer credential.

Also: the one-time setup token was logged at warn AND printed to stdout on
every first boot, putting a live first-admin credential in combined.log,
security.log and `docker logs`. It is now written to the 0600 token file and
only surfaced when that write fails — the last-resort path it existed for. (stable)

* fix(security): codex round 3 — repair the first-run token recovery flow (GHSA-r794)

Two regressions from keeping the setup token out of the logs.

1. server.js decided whether to print the token by calling existsSync() on the
   candidate path. That answers a different question than "did the write
   succeed": a stale, read-only or directory-shaped SETUP_TOKEN reports as
   present, so the banner suppressed the live token and pointed the operator at
   content that is not it — leaving the current token only in combined.log
   under default production logging. setupService now records the path the
   write actually produced and exposes it via writtenSetupTokenFile().

2. The setup screen, its EN/DE strings, README, SIMPLE_SETUP and .env.example
   all still told first-time users to run
   `docker compose logs backend | grep -i "setup token"`. On the normal path
   that command now returns a path banner and no credential, so the documented
   browser-first onboarding could not be completed. They now point at
   `docker compose exec backend cat /app/data/SETUP_TOKEN`, with the log
   fallback described as what it is — the failure path.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit 9a54b6f0231c3285df4c4865eb846e63e1ed0dda)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:18:49 +02:00
Paul Nothaft 11f9f584de fix(security): scope dashboard stats/analytics/activity to the caller's events (stable) (#964)
* fix(security): scope dashboard endpoints to the caller's events (GHSA-c2jj, gqx7, jhcf)

/dashboard/stats, /analytics and /activity are gated only by analytics.view,
which the editor role holds — but the events LIST restricts editors to their
own rows (adminEvents/crud.js: roleName === 'editor' -> created_by =
admin.id). So an editor saw instance-wide totals, and via /analytics
topGalleries other admins' gallery NAMES and SLUGS (the public gallery URL
component), for events invisible to them everywhere else.

- stats: all 10 aggregates scoped (events by id, photos/access_logs by
  event_id).
- analytics: all 8 series/aggregates scoped, including topGalleries. The
  external tracker device breakdown reports instance-wide data with no event
  filter, so a scoped caller falls through to the access_logs heuristic
  instead, which IS scoped.
- activity: feed scoped. activity_logs.event_id is nullable and the join is a
  leftJoin, so system-level rows (logins, settings changes) are deliberately
  excluded for a scoped caller — those are precisely the cross-admin actions
  the advisory is about.

Scoping keys on 'editor' to mirror the events list exactly, so the admin
role's dashboard is unchanged. filterOwnedEventIds uses the broader
'!== super_admin' rule; the two conventions disagree in this codebase and
matching the list is the no-regression choice.

* fix(security): codex round 2 — fix activity misattribution, scope via subquery (GHSA-jhcf, c2jj, gqx7)

- expenseService passed adminId as logActivity's THIRD positional parameter,
  which is eventId — so admin ids were being written into
  activity_logs.event_id. The /activity scoping filter trusts that column, and
  admin/event id sequences overlap, so a foreign admin's expense metadata could
  surface under an editor's event. All 11 calls now pass null for eventId and
  the admin as the actor, which is what they meant.
- Dashboard scoping now uses a SUBQUERY instead of pluck()+whereIn. An editor
  owning more events than the driver's bind-parameter limit (~999 SQLite,
  65535 Postgres) would have turned all three endpoints into 500s once each id
  became a placeholder; below the limit it still re-sent the full list for each
  of the ~10 aggregates per request.

Note: two billInboundNow() calls also end in ', adminId)' but have an unrelated
signature — verified untouched.

* fix(security): codex round 3 — correct legacy accounting activity rows (GHSA-jhcf)

expenseService called logActivity(type, metadata, adminId), but logActivity's
third positional parameter is eventId. Every expense / incoming-invoice entry
therefore stored the ACTING ADMIN'S ID in activity_logs.event_id.

Round 2 scoped the activity feed with
`WHERE activity_logs.event_id IN (SELECT id FROM events WHERE created_by = me)`,
which does nothing about the rows already on disk. Admin ids and event ids are
small integers from the same range, so on any upgraded instance an editor who
owns the event whose id happens to equal another admin's id is served that
admin's accounting activity, verbatim metadata included — GHSA-jhcf, still
live. Migration 168 re-attributes those rows (event_id holds exactly the actor
id that was lost) and then clears event_id so the scope predicate can no longer
match them. All ten activity types are emitted by expenseService and nothing
else, so no row with a genuine event_id is touched.

Also: the round-2 rewrite passed `{ type: 'admin', id: adminId }`
unconditionally, which stored actor_type='admin' with a null id for the
automated mailbox intake (emailIntakeService calls recordInboundDocument with
no adminId). adminActor() restores 'system' attribution for those.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit 459e9e42434defd0dc7b87246e4d894dd47dcc56)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:18:46 +02:00
Paul Nothaft 3b88036fda fix(security): backup/restore hardening — public-dir DB dump, restore path allowlist, gunzip bound, manifest keying (stable) (#962)
* fix(security): stop caller-chosen database backup destination (GHSA-jw8m)

POST /api/admin/database-backup/backup forwarded req.body straight into
databaseBackupService.backup(), which merges options over config:
  const { destinationPath = '/backup/database', ... } = { ...config, ...options }

destinationPath is not a persistable setting — the /config allowlist only
accepts database_backup_* keys — so the request body was its only source.
The built-in `admin` role holds backup.create but neither settings.edit nor
backup.restore, so it could aim a full DB dump (admin bcrypt hashes, gallery
password hashes, encrypted SMTP creds) at the PUBLIC /uploads static mount
(server.js mounts it with no auth middleware) and then fetch it
unauthenticated. Filed low; it is a privilege escalation to unauthenticated
disclosure.

Forward only the real knobs, and only when present so absent keys can't
override config defaults via spread.

* fix(security): backup/restore hardening — restore path allowlist, gunzip bound, manifest checksum keying (GHSA-fw4c, h652, hgp8)

- adminRestore /validate + /start: constrain caller-supplied source and
  manifestPath to the operator-configured backup roots — the SAME set the
  restore wizard discovers from — so disaster recovery from a rescued mount
  still works, with RESTORE_ALLOWED_ROOTS as an escape hatch (GHSA-fw4c).
- restoreService.decompressFile: bound the EXPANDED size and abort the
  pipeline when exceeded; default 50 GB, RESTORE_MAX_DECOMPRESSED_BYTES
  overrides (GHSA-h652).
- backupManifest: BACKUP_MANIFEST_KEY upgrades new manifests to a keyed
  HMAC (GHSA-hgp8). Deliberately opt-in and verify-if-present — the key
  cannot live in the database because the database is inside the backup, so
  a mandatory HMAC would lock operators out of the exact disaster-recovery
  case this exists for.

Also fixes a pre-existing bug found while testing hgp8: the checksum passed
Object.keys().sort() as JSON.stringify's second argument, which is an array
REPLACER (a property allowlist applied at every depth), not a key sorter. All
nested keys — path, size, per-file checksum — were dropped before hashing, so
the file list sat outside the integrity check entirely and a manifest path
could be rewritten to ../../etc/passwd without disturbing the digest. Now
hashes a recursively-canonicalized copy, with the legacy serialization
accepted on validation so existing backups stay restorable.

* fix(security): codex round 2 — unbreak the restore wizard, share checksum verification, guard downgrades

- adminRestore: `source` is usually a SOURCE TYPE ('local'|'s3'|'upload'),
  not a path — restoreService branches on those literals. The containment
  check treated it as a path, so path.resolve('local') fell outside the
  backup roots and BOTH /validate and /start returned 400, blocking every
  normal restore. Type tokens are now excluded from the path check.
- backupManifest: extracted verifyManifestChecksum() as the single source of
  truth for the legacy/keyed fallbacks. restoreService.performPreRestoreValidation
  recomputed the digest itself with the default canonical+keyed settings,
  which rejected EVERY backup written before this batch. It now delegates.
- backupManifest: guard the algorithm downgrade — with a key configured, an
  attacker able to rewrite the backup store could strip checksum_algorithm,
  edit the manifest and recompute a plain SHA-256 that verified. Opt-in via
  BACKUP_MANIFEST_REQUIRE_KEYED so pre-key backups keep restoring by default.

* fix(security): codex round 3 — close two manifest-verification fail-opens (GHSA-hgp8)

verifyManifestChecksum returned valid for a manifest with no
verification.total_checksum at all, and restoreService only called it when
that field was present. Deleting the field was therefore a complete bypass of
the keying work: no digest check, no downgrade guard, no
BACKUP_MANIFEST_REQUIRE_KEYED. Every manifest this codebase writes stamps the
field, so an absent one now fails validation, and the call site invokes the
verifier unconditionally.

Second fail-open: the strict-mode rejection of an unkeyed manifest was gated on
`&& key`, so with BACKUP_MANIFEST_REQUIRE_KEYED=true and no BACKUP_MANIFEST_KEY
configured a plain SHA-256 manifest sailed through. Strict mode is a statement
about the operator's manifests, not about the host — it is exactly the fresh
disaster-recovery box that lacks the secret. The rejection no longer depends on
a key being present.

Claude-Session: https://claude.ai/code/session_01F211U4dDbEj4zXiyKbi9me
(cherry picked from commit 292a5b784acd7f47099aa234c1c2ea00050fca97)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:18:43 +02:00
Paul Nothaft 0c73bf2cdc chore(stable): release 3.45.12 (#955)
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-08-02 20:25:40 +02:00
Paul Nothaft 2c7b5dfd02 fix(security): redact gallery share tokens from analytics tracking (GHSA-7m6c) (stable) (#953)
* fix(security): redact gallery share tokens from analytics page-view tracking (GHSA-7m6c)

* fix(security): codex round-1 — actually disable raw auto-tracking (GHSA-7m6c)

The previous patch was inert: App.tsx passed autoTrack:true (so Umami's
data-auto-track=false was never set) and the sanitized trackPageView had no
caller (useAnalytics sits outside <Router>), so the raw token URL still hit
the collector.

- Umami: drop autoTrack:true → data-auto-track=false; page views now come
  from a sanitized manual tracker.
- Rybbit: its initial-load auto pageview can't be intercepted client-side, so
  use native data-mask-patterns=['/gallery/**'] to strip the token on every
  auto-tracked view; skip manual tracking for it to avoid double counting.
- Mount <AnalyticsRouteTracker/> INSIDE <Router> so manual tracking runs.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 08:39:40 +02:00
Paul Nothaft 5d5db4e766 fix(security): authz/ownership gaps (token binding, auth revocation, feedback/customer ownership, token logging) (stable) (#951)
* fix(security): close authz/ownership gaps (secure-download binding, photo-auth+logout revocation, feedback/customer ownership, token logging)

* fix(security): codex round-1 — complete admin-token invalidation + preserve foreign assignments

- photoAuth: mirror adminAuth's active-admin lookup + iat<password_changed_at
  check in the admin branch, so a deactivated admin or a pre-password-change
  token can no longer fetch every photo (GHSA-x55x was only revoke+cutoff).
- adminAuth logout: revoke req.token (the token adminAuth authenticated with,
  cookie OR header) instead of header-only, and clear the auth cookie — a
  cookie-based logout previously left the JWT live (GHSA-cjqh).
- adminCustomers PUT /:id/events: preserve the customer's existing
  assignments to events the caller does NOT own, so a restricted admin can't
  revoke another admin's customer-event links via full-list replacement.

* fix(security): codex round-2 — don't 403 legit restricted-admin assignment edits

The Manage-galleries dialog submits the full initial assignment list, so a
restricted admin editing a customer that already has a foreign assignment hit
the denied.length 403 before the preservation logic ran. Reject only
NEWLY-supplied foreign/nonexistent ids; retain foreign ids the customer is
already assigned to (they can't be added or removed by a non-owner).

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 08:39:32 +02:00
Paul Nothaft e5dccf1664 fix(security): neutralize spreadsheet formulas in all CSV/export cell-writers (CSV injection cluster) (#949)
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 08:38:55 +02:00
Paul Nothaft bfafecedc7 fix(security): unauth share_token leak (HIGH) + restore path-traversal, logo file-read (stable) (#947)
* fix(security): stop unauth share_token leak + block restore path-traversal, logo-path file read, branding path keys

* test: update resolveLogoFile for the c7x5 containment (reject outside-storage absolute paths, keep inside)

* fix(security): codex round-1 — escape LIKE wildcards in share-link resolve, keep in-storage absolute logos, guard restore verification

- shareLinkService: escape %/_ in the link_partial LIKE fallback so an
  anonymous /resolve/____… wildcard can't match an arbitrary share_link and
  leak its bearer token (reopened GHSA-rh8r). Explicit ESCAPE for SQLite.
- resolveLogoFile: re-add the raw absolute candidate but keep it subject to
  the storage-root containment filter (GHSA-c7x5) so legit in-storage
  absolute logos resolve while /etc/passwd stays rejected.
- restoreService: apply the same pathEscapes guard in post-restore
  verification so a skipped traversal entry isn't fs.access'd/hashed.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 08:38:47 +02:00
Paul Nothaft 2c5a094c5c chore(stable): release 3.45.11 (#936)
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-08-01 17:38:06 +02:00
Paul Nothaft 2462ba6897 fix(security): close authorization/ownership gaps (token scope, mass-assignment, category hero, project docs) (stable) (#944)
* fix(security): close authorization/ownership gaps (token scope, mass-assignment, category hero, project docs)

* fix(security): block archive columns in event mass-assignment per review

* fix(security): comprehensive event mass-assignment denylist + deal-cascade cross-domain permission gate (codex r2)

* fix(security): case-insensitive complete event denylist + project_id + empty-update no-op (codex r3)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-01 17:36:57 +02:00
Paul Nothaft 90275f88e9 fix(security): resolve DNS before vetting external hostnames (SSRF cluster) (stable) (#942)
* fix(security): resolve DNS before vetting external hostnames (SSRF cluster)

* fix(security): harden SSRF fix per review (rsync backup path, S3 config-save, webhook transient-DNS retry)

* fix(security): S3 endpoint validation on any endpoint update + no-connect on unresolved webhook host (codex r2)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-01 17:36:51 +02:00
Paul Nothaft 34a7b1c013 fix(security): block guest access to hidden/client-only photos across bulk + secure routes (stable) (#940)
* fix(security): block guest access to hidden/client-only photos across bulk + secure routes

* fix(security): harden hidden-photo fix per review (stale ZIP cache, legacy token mint, SQLite bool, client rebuild)

* fix(security): invalidate ZIP cache on photo visibility/category change (codex r2)

* fix(security): recheck photo visibility at signed/secure serve time (TOCTOU) + invalidate ZIP on client visibility change (codex r3)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-01 17:36:46 +02:00
Paul Nothaft 7419c68337 fix(security): bump sanitize-html to 2.17.5 (CVE-2026-53606) (stable) (#938)
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-01 17:36:40 +02:00
Paul Nothaft fc99e2b233 fix(uploads): prevent cross-photo contamination from filename collisions and non-atomic writes (#931) (stable) (#934)
* fix(uploads): prevent cross-photo contamination from filename collisions and non-atomic writes (#931)

* test: pin the suffixed photo filename format in the NFD pipeline suite (#931)

* chore(deps): promote p-limit to a direct dependency for the watermark limiter (#931)

* test: make the suffix-uniqueness check deterministic-in-practice (#931)

* fix(uploads): widen the anti-collision suffix to 48 bits (#931)

* fix(uploads): hide staging files from list() + share one watermark limiter process-wide (#931)

* fix(uploads): reclaim orphaned staging files + revalidate watermark settings in queued jobs (#931)

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-01 12:29:23 +02:00
Paul Nothaft 7974b9c6d7 chore(stable): release 3.45.10 (#923)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-30 21:20:10 +02:00
Paul Nothaft 60cbda5b22 fix(security): close GHSA-g94x (cross-gallery photo read) + GHSA-pv6w (admin DB export) (stable) (#925)
* fix(security): close two access-control advisories (GHSA-g94x, GHSA-pv6w) (stable)

GHSA-g94x-8vv8-3c9f (HIGH) — the secure-image VIEW route
(/secure-images/:slug/secure/:photoId/:token) validated only the token
signature and took the gallery/photo from the URL, so a token minted on
any PUBLIC gallery read every other gallery's photos with no password
(its download sibling has verifyGalleryAccess; the view route can't —
it serves via <img src> with no header). Bind the token to its scope
instead: the URL photoId must equal the token's minted photoId (photos
belong to exactly one gallery, and minting is gallery-scoped), and the
gallery embedded in the token's sessionId must equal the URL gallery.

GHSA-pv6w-rj34-wj9v (MEDIUM) — GET /admin/backup/picpeak/export dumps
every table unredacted (bcrypt hashes, 2FA, SMTP/SSO/WhatsApp/webhook/S3
secrets) and was gated only by backup.create, which the built-in admin
role holds. Gate it behind super_admin, matching the restore side
(backup.restore, already admin-denied) and the masked config APIs.

Regression tests pin both: cross-gallery token reads 403 (photo and
gallery checks), backup export 403 for admin / passes for super_admin.

Stable port of #924. secureImages on stable has no reveal-mode block, so
only the token-binding checks are added; the backup export gate is
identical.

* fix(security): review follow-ups on the export gate (GHSA-pv6w)

- test: place the mocked export in its own mkdtemp dir. The route
  recursively deletes path.dirname(filePath) after download, so a stub
  in bare os.tmpdir() made the super_admin test wipe the whole temp
  root — other jest workers' DB files included (latent CI flake).
- ui: hide PicpeakExportCard from non-super_admins. The role keeps
  settings.view + backup.create, so after the gate its Download button
  always 403'd with a generic toast; gate the card on role super_admin
  to match the endpoint.

* fix(security): keep the token-mismatch audit values within varchar(20) (GHSA-g94x review)

image_access_logs.access_type is varchar(20) (migration 038), but
'token_gallery_mismatch' is 22 chars — on Postgres the audit write
threw value-too-long and logImageAccess swallowed it, so the security
event went unrecorded (the 403 still fired; log is best-effort). Shorten
to 'photo_mismatch' / 'gallery_mismatch' (14/16).

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 14:24:34 +02:00
Paul Nothaft a27d19b4d1 fix(admin): expose view/download counters in the admin photos list (#895 follow-up) (stable) (#915)
* fix(admin): expose view/download counters in the admin photos list (#895 follow-up) (stable)

st-ivan's re-test after #904: statistics panel and event summary now
agree, but the per-image Engagement column still shows 0. Root cause:
the admin photos LIST endpoint maps rows to an explicit response object
that includes like/comment/rating/favorite counts but never included
view_count or download_count — the grid reads photo.view_count ?? 0,
so the column showed 0 regardless of what the DB counted. This mapper,
not stale data, is also why per-image downloads always displayed 0 in
the original report.

Suite extended with a list-endpoint assertion (beacon + download, then
the admin list reflects 1/1 and untouched photos 0/0). The skip test now
neutralizes the route's background pre-zip build, whose async ENOENT
against the intentionally missing file could land mid-suite.

Includes the one-line chunkedUploadService unref from #911 so the test
suite can mount adminPhotos regardless of merge order (identical change,
merges cleanly either way).

* test: widen the fire-and-forget settle window (#895 follow-up)

The 100ms settle was marginal on loaded CI runners — the counter
increments are deliberately fire-and-forget, and the 909 PRs flaked on
exactly these assertions. 400ms keeps the suite fast while giving slow
runners room.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 12:15:38 +02:00
Paul Nothaft d68d84e5c8 fix(admin): serve videos with their real MIME type in the admin photo view (#908) (stable) (#911)
* fix(admin): serve videos with their real MIME type in the admin photo view (#908) (stable)

The admin view route built Content-Type from the filename extension —
image/<ext> — which is invalid for videos (image/mp4). The admin player
fetches this URL into a blob that inherits the type, and browsers
refuse to play a <video> blob labeled image/*: blank/grey preview,
while download (which already uses photo.mime_type) worked fine.

Stored mime_type now wins; videos without one fall back to video/mp4,
images to the extension, and extensionless files to image/jpeg instead
of the equally invalid bare 'image/'.

Also unrefs chunkedUploadService's module-level hourly cleanup interval:
it kept Jest from exiting for any suite requiring adminPhotos (it's why
adminPhotos.reference sits on the CI ignore list). Production behavior
unchanged — the HTTP listener keeps the process alive.

New adminPhotoContentType suite pins all four MIME cases.

* fix(admin): harden admin photo Content-Type resolution (#908 review round)

External review findings, all verified:

- The header is now 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 was a same-origin XSS hazard.
- MIME-less videos map from the extension via the shared
  EXTENSION_TO_MIME (.mov → video/quicktime, .webm → video/webm)
  instead of a blanket video/mp4 that would mislabel them.
- Images ignore the stored MIME entirely: migration 039 backfilled
  image/jpeg onto every legacy row (PNGs included), so trusting it
  would regress previously-correct extension-derived types. Extension
  wins, normalized (jpg → image/jpeg).

Suite extended to 8 MIME cases including the XSS guard and the
039-backfill immunity.

* fix(admin): validate stored video MIME as a full header-safe token (#908 review round 2)

A prefix check let malformed client-stored values through:
'video/mp4\r\nX: y' makes res.setHeader throw ERR_INVALID_CHAR — a
permanent 500 for that photo — and a bare 'video/' is an invalid type.
Strict /^video\/[\w.+-]+$/ now gates the stored value; anything else
falls back to the extension map. Two new tests pin both shapes.

* fix(admin): map-only image Content-Type — no raw extension interpolation (#908 review round 3)

image/${ext} could synthesize image/svg+xml (scriptable when served
inline) or header-invalid values from client-controlled chunked-upload
filenames. The shared EXTENSION_TO_MIME map is now the allowlist on the
image side too; unmapped extensions serve as image/jpeg — browsers
sniff image bytes in img/blob contexts, so a mislabel is harmless where
an injected type is not.

* fix(admin): own-property lookup in the extension MIME map (#908 review round)

A client-controlled filename ending in .constructor / .__proto__ /
.toString made EXTENSION_TO_MIME[ext] return an inherited Object.prototype
member (truthy), and the downstream extMime.startsWith threw —
a permanent 500 on the admin view for that photo instead of the JPEG /
mp4 fallback. hasOwnProperty-gated now; test pins both a .constructor
image and a .__proto__ video.

* fix(admin): honor safe stored image MIME for auto-imported formats (#908 review round 2)

My previous round made the image side map-only to dodge the migration
039 image/jpeg backfill and image/svg+xml — but that regressed the S3
auto-importer (STORAGE_AUTO_IMPORT), which stores correct types for
avif/bmp/tiff/heic whose extensions aren't in EXTENSION_TO_MIME. Those
now served as image/jpeg (JPEG-labelled non-JPEG bytes).

Precedence is now mapped-extension (still corrects the 039 backfill on
PNGs) -> stored MIME IF in a safe raster allowlist (avif/bmp/tiff/heic
+ the mapped ones) -> image/jpeg. Allowlist, not a regex: image/svg+xml
stays excluded (scriptable inline). Tests pin avif preserved and svg
degraded to jpeg.

* fix(admin): allow any header-safe raster MIME, deny svg/xml (#908 review round 3)

The round-2 hand-listed Set kept missing formats the S3 auto-importer
stores (apng/ico/jxl beyond avif/bmp/tiff). Replace it with a regex:
honor image/<token> EXCEPT the scriptable svg / *+xml family. Covers
every current and future raster type in one rule while still blocking
inline-scriptable svg and header injection. Tests pin apng + x-icon
preserved, svg still degraded to jpeg.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 12:14:58 +02:00
Paul Nothaft 6891769124 fix(admin): stop marking events expired up to 24h early (#909) (stable) (#917)
* fix(admin): stop marking events expired up to 24h early (#909) (stable)

differenceInDays truncates to whole days, so an event expiring in a few
hours returned 0 and three admin surfaces treated it as gone:

- EventsListPage: status chip said 'Expired' (days <= 0) while the
  public gallery — which compares real timestamps — correctly showed
  'expires in X hours'. This is the reporter's exact symptom.
- EventDetailsPage: same isExpired math on the detail view.
- AdminDashboard: the expiring-soon card showed '0 days left' on the
  final day.

Expired is now gated on the actual timestamp (expires_at <= now), and
the countdown chips use ceiling days so the last day reads '1 day
left' instead of flipping to Expired/0.

* fix(admin): drop already-expired events from the dashboard card (#909 review round)

The expiring-soon card ran Math.max(1, ceil(delta)), so an event that
expired while the dashboard sat open (its query isn't polled) showed
'1 day left' indefinitely from the stale cached row. Expired rows are
now filtered out before render; the delta is therefore always positive
and the clamp is gone.

* fix(admin): refresh expiry status live at the boundary (#909 review round 2)

Two review findings on the admin expiry surfaces:

- The dashboard 'expiring soon' card, list badges, and detail banner are
  all computed inline from Date.now() at render, so a page left open
  across an event's expiry kept showing 'active'/'1 day left' until an
  unrelated render — which for editor/viewer roles (no health poll)
  never happens.
- My round-1 client-side filter on the dashboard desynced the visible
  list from the cached total/stat ('no events expiring' beside 'view
  all N').

Both are fixed by new useExpiryRefresh: it fires once at the soonest
future expiry (setTimeout, overflow-guarded). The dashboard refetches
its expiring + stats queries — the backend already excludes expired
events, so rows/total/stats come back consistent (filter removed). The
list and detail pages bump a tick so the inline badges recompute. Hooks
are placed above the loading early-returns (rules-of-hooks is disabled
in eslint, so this was a latent crash otherwise).

* fix(admin): expiry-refresh precision + filtered refetch (#909 review round 3)

Three refinements to round-2's live-expiry work:

- useExpiryRefresh now re-arms past setTimeout's ~24.8-day overflow
  limit (capped wake-up that re-evaluates) instead of dropping the timer,
  so a page mounted for weeks still updates.
- The dashboard requests the expiring list ordered by expires_at asc, so
  the five shown rows ARE the soonest to expire — the timer schedules
  against the true next boundary even when >5 events are expiring
  (getEvents gains optional sortBy/sortOrder; backend already whitelists
  expires_at).
- EventsListPage refetches instead of only re-rendering at the boundary:
  under the 'expiring' filter the backend drops expired rows, so a plain
  tick would leave a stale 'Expired' row + total. refetch keeps rows and
  totals correct under every filter.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 12:14:54 +02:00
Paul Nothaft b32ba1ed6b ci: batch stable releases into one daily version (stable) (#920)
* ci: batch stable releases into one daily version (stable)

The stable release PR was auto-merged the instant it went green, so a
day with N bugfixes produced N patch releases (3.45.8 AND 3.45.9 on
2026-07-29 alone) — N upgrade notifications for stable users and N
full Docker build cycles.

Fixes now accumulate in release-please's rolling release PR and are
cut as ONE version per day by release-stable-daily.yml (18:00 UTC).
Approval/merge mechanics are unchanged from the inline step (#719):
approve as github-actions[bot], auto-merge as the PAT so the merge
triggers the tag-cutting run.

- Urgent fix? workflow_dispatch the daily job or merge the release PR
  by hand — the schedule is a default, not a gate.
- Beta is untouched: instant beta releases are load-bearing for
  same-day reporter verification.
- schedule only fires from the default branch; the stable copy of the
  new workflow is inert and exists to keep branches in sync.

* ci: harden the daily stable-release cut (review round) (stable)

Mirror of the #919 hardening — fork-PR head-name spoof (require
--base stable + same-repo head) and no longer swallowing the
auto-merge-enable failure on the sole automatic stable cut.

* ci: accept an immediately-merged release PR as success (review round 2) (stable)

Mirror of #919: MERGED state = success (the normal 18:00 case where
checks were already green and --auto merges immediately), pending
auto-merge = success, still-open-no-auto-merge = real failure.

* ci: read release-PR state + auto-merge in one snapshot (review round 3) (stable)

Mirror of #919 — collapse the two racing gh pr view calls into one.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-30 12:14:31 +02:00
Paul Nothaft f99357460f chore(stable): release 3.45.9 (#907)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-29 16:02:51 +00:00
Paul Nothaft 90b589a88e fix(analytics): make per-photo view/download counters actually count (#895) (stable) (#905)
* fix(analytics): make per-photo view/download counters actually count (#895) (stable)

Three stacked defects behind 'per-image stats stay 0':

- photos.view_count had NO writer anywhere — the admin IMAGES table and
  photo viewer display it, so it was permanently 0. It now increments
  when the full-size photo or its preview tier is served, excluding the
  slideshow kiosk (migration 138 design) and follow-up video Range
  requests (seeks are not views). Fire-and-forget so analytics can
  never fail the byte-serving path.
- Zip downloads (download-all, presigned download-all,
  download-selected) never incremented per-photo download_count — only
  single-photo downloads did, so zip-heavy galleries showed 0 forever.
  The zip routes now bump exactly the photos that went into the archive
  (the prebuilt-zip path mirrors the archive builders' category filter).
- Every admin surface used a different definition of 'downloads', which
  is the reporter's 46 vs 45 vs 0: event details counted only
  action='download' (no zips at all), the dashboard counted
  download+download_all but silently EXCLUDED download_selected and
  download_all_presigned. All queries now share one action set:
  download, download_all, download_all_presigned, download_selected.

New photoEngagementCounters suite pins all of it (7 tests).

* fix(analytics): count views via an explicit lightbox beacon (#895 review round)

External review flagged that request-level view counting is wrong in
both directions: the lightbox preloads prev/next neighbours (3 fetches
per open) while a preloaded neighbour promoted by a swipe is never
re-fetched (#505 keeps the DOM node), and enhanced/maximum galleries
never hit /photo at all (bytes come from /api/secure-images).

- Views now count via POST /:slug/photo/:photoId/view, fired by the
  lightbox exactly when a photo becomes the visible slide; the
  serving-route increments are removed. Covers protected galleries and
  the preview tier uniformly; slideshow kiosk stays excluded.
- bumpEventDownloadCounts mirrors downloadZipService._build (ALL event
  photos) — the category filter mismatched the prebuilt zip's actual
  contents. (That the builder ignores per-category allow_downloads is a
  separate pre-existing issue.)
- Zip loops count only successfully appended entries, with a pre-append
  storage stat: a lazy stream's async error bypassed the per-photo
  catch and hung the whole response — pre-existing bug, now fixed.

Suite extended to 9 tests (beacon semantics, serve-does-not-count,
skipped-entry exclusion).

* fix(analytics): fire the view beacon from the premium lightbox too (#895 review round 2)

gallery-premium events use yet-another-react-lightbox inside
GalleryPremiumLayout instead of PhotoLightbox, so the layout never
counted views. yarl's on.view fires on open and on every slide change —
identical semantics to the PhotoLightbox beacon.

Also documents the accepted prebuilt-zip approximation: _build can skip
entries whose watermark step fails and still publish the archive;
counting those exactly would need a persisted zip manifest.

* perf(analytics): skip the per-entry zip preflight on S3 (#895 review round 3)

The pre-append source check exists for LocalFs's lazy createReadStream
(async error would kill the whole zip response). S3's get() awaits
GetObject and rejects inside the loop's try/catch on a missing key, so
a HEAD per entry was a redundant serial round trip — 500 extra HEADs
on a 500-photo zip.

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-29 17:59:06 +02:00
Paul Nothaft 1ad8ad5b68 chore(stable): release 3.45.8 (#903)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-29 10:52:37 +00:00
Paul Nothaft 962f1d9586 fix(tests): raise jest timeouts to the 120s convention (stable) (#902)
Stable backport combining #860 (never reached stable) and #900:

- jest.config.js gains testTimeout: 120000 — stable still ran on Jest's
  5s default for anything unpinned, while its migration chain (134 core
  migrations via backports) is nearly as long as beta's.
- All 19 suite-level jest.setTimeout(30000/60000) pins raised to 120s;
  local pins override the config default (#860's rationale).
- All 15 hook-ARGUMENT timeout pins on migration-booting beforeAll
  hooks raised to 120s (#900's rationale — the 3.97.0-beta.0 release PR
  failed on exactly this class on the beta side).

Untouched: the three suites whose pinned hooks don't run migrations
(webhookDelivery, imageProcessor.storage, storageBackend) and
publicQuotes' 30s pin on the rate-limit lockout test.

No test logic changed.

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-29 12:48:50 +02:00
Paul Nothaft a7885846ac chore(stable): release 3.45.7 (#881)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-27 07:58:07 +00:00
Paul Nothaft d868aac703 fix(security): close 5 Trivy alerts — postcss/tar bumps + drop npm from the runtime image (stable) (#879)
* fix(security): close the 5 open Trivy alerts — dep bumps + drop npm from the runtime image

Backend deps:
- postcss 8.5.10 -> 8.5.18 (CVE-2026-45623, GHSA-r28c-9q8g-f849; the pin
  exists to force sanitize-html's transitive copy onto a fixed version)
- tar pin/override >=7.5.16 -> >=7.5.21, resolves 7.5.22
  (GHSA-r292-9mhp-454m)

Runtime image:
- Remove the npm CLI from the final stage instead of upgrading it: npm's
  bundled node_modules ship tar 7.5.19 and brace-expansion 5.0.7 (no npm
  release bundles the fixed versions — checked 11.18.0 and 12.0.1), and
  npm never runs in production. wait-for-db.sh now invokes the migration
  runners via node directly. This ends the recurring npm-bundled-CVE
  alert class; the previous 'npm install -g npm@11' line was itself a
  patch for the last batch. (stable)

* fix(restore): run post-restore migrations via node — the image ships no npm

restoreService still shelled out to 'npm run migrate:safe' after a
restore; with npm removed from the runtime image that would ENOENT into
the non-fatal catch, silently leaving a restored older backup on a
schema behind the running code until the next container restart. Invoke
migrations/run-migrations-safe.js through node directly, matching
wait-for-db.sh. The PR #596 source-contract test now pins the new
invocation. (stable)
2026-07-27 09:54:43 +02:00
Paul Nothaft 577b7fa6ae chore(stable): release 3.45.6 (#877)
Build and Push Docker Images / summary (push) Blocked by required conditions
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
2026-07-27 07:10:18 +00:00
Paul Nothaft a27c705e39 fix(backup): make backup settings actually apply (#871) (stable) (#875)
* fix(backup): make backup settings actually apply (#871) (stable)

- Wire the What-to-Backup toggles into the walker: honor
  backup_include_thumbnails / backup_include_photos (opt-out,
  default ON) and accept the UI's backup_include_archives spelling
  for the archived gate (the engine expected _archived, so the
  Archives checkbox silently never worked).
- Fix the 167.6 TB dashboard size: file_size_bytes is a bigint that
  node-postgres returns as a string, and the S3 path concatenated it
  onto the byte counter; coerce to Number at the source.
- Compute the real next scheduled run (cron-parser) and return it as
  nextBackup; the UI read a field the API never sent and rendered a
  hardcoded 'Not scheduled'. A named schedule label now beats the
  stray default cron the UI always sent, which silently turned
  weekly schedules into daily 03:00 runs.
- Never back up filesystem noise (.nfs* silly-renames, .DS_Store,
  Thumbs.db) and honor backup_exclude_patterns in the walker
  (previously rsync-only).
- Remove the compression/encryption toggles from the configuration
  UI: no backend implementation exists, and collecting an encryption
  passphrase while uploading plaintext is a false promise.

* fix(backup): close the review gaps in the settings wiring (stable)

- The UI's backup_include_archives now beats the migration-seeded
  backup_include_archived: every install has the singular key seeded
  true, so the alias-only-when-absent lookup made unchecking Archives
  a no-op.
- rsync destinations now receive the de-selected What-to-Backup paths
  and the noise filters as anchored --exclude args; previously rsync
  synced the whole storage root and the walker's selection only shaped
  the manifest, which then misreported what was actually transferred.
- Escape regex metacharacters in the walker's glob matcher: '.nfs*'
  compiled to /^.nfs.*$/ whose leading dot matched any character, so
  files like anfs-photo.jpg were silently dropped from backups.
- The Backup Coverage report now uses the same gate as the walker
  (new 'skipped-by-setting' status) instead of re-implementing it
  without the opt-out toggles and the archives alias.

* fix(backup): make the coverage diagnostics agree with the walker

- The coverage table shows the alias-aware flag value the gate actually
  used, instead of the seeded backup_include_archived shadowed by the
  UI's plural key (true next to a 'Gated off' badge).
- skipped-by-setting paths are now counted in the coverage summary
  (backend, TS contract, summary card, EN/DE locales) so the totals
  reconcile again when Photos or Thumbnails is unchecked.
- The form's thumbnail default now matches the backend's never-saved
  fallback (include): the checkbox no longer shows 'off' while
  thumbnails are being backed up, and saving an unrelated setting no
  longer flips the backup scope. (stable)

* fix(backup): keep custom crons, exclude disabled rows from rsync, normalize flag display

- Saving a named schedule no longer wipes the stored custom cron: the
  backend already prefers the label, so the cron field stays inert for
  named schedules and is preserved for switching back to Custom. A
  custom schedule now validates the 5-field expression before saving
  (the backend silently fell back to daily 02:00 on a blank value).
- resolveExcludedBackupPaths now also returns rows disabled via
  include_in_default, so rsync excludes them; the enabled-only loader
  hid them and rsync transferred their contents anyway.
- The coverage table normalizes flag values like the walker does —
  Boolean('false') displayed true beside a gated-off badge. (stable)
2026-07-27 09:06:50 +02:00
Paul Nothaft b0e9145bba chore(stable): release 3.45.5 (#873)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-26 18:41:48 +00:00
Paul Nothaft 39696d42fe fix(security): bump backend deps to close all 14 open Trivy code-scanning alerts (stable) (#870)
* fix(security): bump backend deps to close all open Trivy code-scanning alerts (stable)

- axios 1.16.0 -> 1.18.1 (GHSA-gcfj-64vw-6mp9 high + 10 medium advisories)
- sharp 0.34.3 -> 0.35.3 (GHSA-f88m-g3jw-g9cj, inherited libvips CVEs)
- mailparser 3.9.9 -> 3.9.14 (pulls linkify-it 5.0.2, CVE-2026-59887)
- brace-expansion override >=5.0.6 -> >=5.0.7 (CVE-2026-13149)
- body-parser 1.20.4 -> 1.20.6 via lockfile refresh (CVE-2026-12590)

* fix(images): migrate removed sharp failOnError option and enforce Node >=20.9 (stable)

sharp 0.35 drops the deprecated failOnError constructor option, so
recoverably corrupt images would start failing upload validation and
thumbnail generation; use the failOn: 'none' equivalent instead.

sharp 0.35 also requires Node >=20.9: declare it in engines and make
picpeak-setup.sh compare the full version instead of only the major,
so native installs on Node 20.3-20.8 upgrade instead of breaking.

* fix(setup): align the Node floor with the whole dependency tree and gate native updates (stable)

html-to-text@10 needs Node >=20.19 and the glob/minimatch family excludes
Node 21, so declare engines as ^20.19.0 || >=22 and enforce the same range
in picpeak-setup.sh. Also run install_nodejs at the start of
update_native_installation so existing native installs on an old Node get
upgraded before the service is stopped, instead of restarting broken.

* fix(setup): make the update-path Node gate actually work (stable)

--update dispatches before detect_os, so install_nodejs saw an empty
PACKAGE_MANAGER, matched no install branch, and reported success on the
old runtime. Detect the OS on demand and re-verify the installed version
afterwards, failing loudly (before the service is stopped) when the
runtime still misses the engines range, e.g. a Node 21 that package
managers refuse to downgrade.
2026-07-26 20:38:16 +02:00
Paul Nothaft 50f5ca1d5b fix(security): read the password-complexity key the settings UI writes (stable) (#844)
* fix(security): read the password-complexity key the settings UI writes

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

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

On SQLite the TEXT column returns the JSON-stringified value
('"very_strong"'), but on Postgres (production default) setting_value
is a json column and arrives already decoded ('very_strong') — the bare
JSON.parse threw and the outer catch silently fell back to 'moderate'
again. Parse with fallback, mirroring getAppSetting's documented
pattern; test now covers both driver shapes + the empty-value default.
2026-07-19 20:04:35 +02:00
Paul Nothaft 11b6490e4c chore(stable): release 3.45.4 (#831)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-17 19:43:45 +00:00
Paul Nothaft 1cff576439 Merge pull request #829 from PicPeak/fix/hero-logo-visible-null-validation-stable
fix(events): accept hero_logo_visible: null on create/update (#822) (stable)
2026-07-17 21:39:26 +02:00
Paul Nothaft 8978acdb49 fix(events): accept hero_logo_visible: null on create/update (#822)
hero_logo_visible is nullable — null means "inherit the global
branding_logo_display_hero toggle" (#756, migration 152). But the create and
update validators used `.optional()` without `{ nullable: true }`, which only
skips `undefined`; an explicit `null` still ran `.isBoolean()` and failed with
HTTP 400 "Invalid value". Saving an event with `hero_logo_visible: null` (the
inherit state the frontend sends) was rejected on v3.45.2.

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

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

Adds smoke tests: PUT accepts hero_logo_visible: null and stores NULL; a
non-boolean value is still rejected.
2026-07-17 21:14:12 +02:00
317 changed files with 23902 additions and 2508 deletions
+3 -2
View File
@@ -56,8 +56,9 @@ DB_NAME=picpeak_prod
# Admin Account (initial setup) — OPTIONAL
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
# open /admin and PicPeak shows a setup screen. The one-time setup token is
# printed to the backend logs (`docker compose logs backend | grep -i "setup token"`)
# and saved to data/SETUP_TOKEN.
# written to data/SETUP_TOKEN with mode 0600 — read it with
# `docker compose exec backend cat /app/data/SETUP_TOKEN`. It is NOT logged
# unless that write fails, so it never sits in `docker logs`.
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
# credentials written to data/ADMIN_CREDENTIALS.txt).
#ADMIN_USERNAME=admin
+16
View File
@@ -203,6 +203,14 @@ jobs:
format: 'sarif'
output: 'trivy-backend-${{ env.PLATFORM_PAIR }}.sarif'
severity: 'CRITICAL,HIGH'
# Base-image CVEs with no released fix are not actionable: the
# Dockerfiles already run `apt-get upgrade -y` behind a CACHEBUST,
# so a fix lands in the next build automatically. Reporting them
# buries the findings someone can actually do something about.
# Dropping them is also the precondition for ever setting
# exit-code: 1, which build-backend's comment flags as a
# deliberate follow-up.
ignore-unfixed: true
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
@@ -425,6 +433,14 @@ jobs:
format: 'sarif'
output: 'trivy-frontend-${{ env.PLATFORM_PAIR }}.sarif'
severity: 'CRITICAL,HIGH'
# Base-image CVEs with no released fix are not actionable: the
# Dockerfiles already run `apt-get upgrade -y` behind a CACHEBUST,
# so a fix lands in the next build automatically. Reporting them
# buries the findings someone can actually do something about.
# Dropping them is also the precondition for ever setting
# exit-code: 1, which build-backend's comment flags as a
# deliberate follow-up.
ignore-unfixed: true
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
+6 -26
View File
@@ -27,32 +27,12 @@ jobs:
manifest-file: .release-please-manifest.json
target-branch: stable
# Auto-approve + auto-merge the open stable release PR. See the beta
# workflow for the full rationale. Skipped on the release-cutting run and
# whenever no PAT is configured.
- name: Auto-approve and enable auto-merge on the release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout in this job — set the repo explicitly so gh works
# without a git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
exit 0
fi
pr=$(gh pr list --head release-please--branches--stable --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN, ≠ the PAT author) so it
# is a valid review; enable auto-merge as the PAT so the merge commit is
# attributed to a real identity and triggers the tag-cutting run (#719).
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
# NOTE: stable release PRs are intentionally NOT auto-merged here
# anymore. Fixes accumulate in the rolling release PR and are cut as
# ONE patch version per day by release-stable-daily.yml (18:00 UTC,
# or on demand via workflow_dispatch / a manual merge of the release
# PR). Beta keeps instant releases — see release-please-beta.yml —
# because same-day reporter verification depends on it.
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
@@ -0,0 +1,86 @@
name: Cut Stable Release (daily batch)
# Stable fixes accumulate in release-please's rolling release PR instead of
# each cutting its own patch version (the old per-merge auto-merge produced
# e.g. 3.45.8 AND 3.45.9 on the same day). This workflow merges the open
# stable release PR once a day, so a day of N bugfixes ships as ONE version
# with all N changelog entries — and one Docker build instead of N.
#
# - schedule only fires from the default branch (main); the stable copy of
# this file is inert and exists to keep the branches in sync.
# - Need a release NOW? Run this via workflow_dispatch, or merge the
# release PR by hand — the schedule is a default, not a gate.
# - Approval/merge mechanics mirror the old inline step (#719): approve as
# github-actions[bot] (GITHUB_TOKEN, a valid distinct reviewer), enable
# auto-merge as the PAT so the merge attributes to a real identity and
# triggers the tag-cutting run. --auto waits for green checks.
on:
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
merge-stable-release-pr:
runs-on: ubuntu-latest
steps:
- name: Approve and enable auto-merge on the open stable release PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout — set the repo explicitly so gh works without a
# git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping (manual review required)."
exit 0
fi
# Strict selection (review P1): this job runs daily even without a
# stable push, and `gh pr list --head` matches the branch NAME only
# — a fork PR can spoof `release-please--branches--stable`. Pin the
# base to stable AND require a same-repo head (isCrossRepository
# == false); a fork PR is cross-repository, so it can never be
# picked and auto-merged with the privileged PAT.
pr=$(gh pr list \
--base stable \
--head release-please--branches--stable \
--state open \
--json number,isCrossRepository \
--jq '[.[] | select(.isCrossRepository == false)] | .[0].number // empty')
if [ -z "$pr" ]; then
echo "No open same-repo stable release PR — nothing to cut today."
exit 0
fi
# Approve is tolerant — a pre-existing approval already satisfies
# branch protection and re-approving can return non-zero.
gh pr review "$pr" --approve --body "Automated approval — daily stable release batch (release-please version bump + changelog)." || echo "::warning::approve returned non-zero (PR may already be approved)"
# But the auto-merge enable is the load-bearing step: this scheduled
# job is the ONLY automatic stable cut, so DON'T swallow its failure
# (review P2) — an expired/under-scoped PAT would otherwise stop
# releases while the workflow stays green.
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto
# `gh pr merge --auto` merges IMMEDIATELY when the required checks
# are already green — the normal case at 18:00, since the fixes
# merged hours earlier and CI passed. So success is EITHER the PR is
# already merged OR an auto-merge request is now pending; only a PR
# that is still open with no auto-merge request is a real failure
# (expired/under-scoped PAT) worth failing the job on (review round 2).
# One snapshot of both fields (review round 3): querying state and
# autoMergeRequest separately races — auto-merge can complete
# between the two calls, so the first sees OPEN and the second sees
# the request already cleared on the now-merged PR → false failure.
read -r state automerge < <(gh pr view "$pr" --json state,autoMergeRequest \
--jq '[.state, (.autoMergeRequest != null)] | @tsv')
if [ "$state" = "MERGED" ]; then
echo "Stable release PR #$pr merged immediately (checks were already green)."
elif [ "$automerge" = "true" ]; then
echo "Auto-merge enabled on stable release PR #$pr — merges when checks are green."
else
echo "::error::stable release PR #$pr is still open with no auto-merge — check RELEASE_PLEASE_TOKEN scope/expiry."
exit 1
fi
+26
View File
@@ -30,6 +30,29 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
# The .picpeak restore suites gate their real-Postgres cases behind
# PICPEAK_PG_TEST_URL and `describe.skip` themselves out when it is
# unset — so until now they never ran here. That hid the half that
# matters: sequence resync, operator/role preservation across a
# cross-instance restore, and (with #1041) whether a SQLite-shaped
# row actually lands in Postgres with the right STORED VALUES rather
# than merely not throwing. Everything else in the suite still runs
# on SQLite; this service only un-gates those cases.
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: picpeak
POSTGRES_PASSWORD: testpass
POSTGRES_DB: picpeak_test
options: >-
--health-cmd "pg_isready -U picpeak -d picpeak_test"
--health-interval 2s
--health-timeout 2s
--health-retries 30
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -52,6 +75,9 @@ jobs:
# The S3 path itself is covered separately by the integration
# suite when MinIO is provisioned.
SKIP_S3_TESTS: 'true'
# Un-gates the real-Postgres cases in the .picpeak restore suites
# (see the `services:` note above). Absent it they silently skip.
PICPEAK_PG_TEST_URL: 'postgres://picpeak:testpass@127.0.0.1:5432/picpeak_test'
run: |
# Excluded suites — fail on upstream/beta too, tracked
# separately as test-infra debt:
+24 -2
View File
@@ -130,5 +130,27 @@ docker-compose.dev.yml
# New layout development files
new-layouts/
# Generated CRM/accounting documents (runtime) — never commit
backend/storage/business-docs/
# Backend runtime storage (generated media, previews, thumbnails,
# CRM/accounting documents) — never commit. Matches main: a dev instance
# 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.45.3"}
{".":"3.46.10"}
+233
View File
@@ -5,6 +5,239 @@ 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.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)
### Bug Fixes
* **admin:** the "Uncategorized" photo filter returns every photo ([#1211](https://github.com/PicPeak/picpeak/issues/1211)) ([#1215](https://github.com/PicPeak/picpeak/issues/1215)) ([15c844d](https://github.com/PicPeak/picpeak/commit/15c844db067de7bc04a88ddd407f3ed8f5df0fe2))
## [3.46.6](https://github.com/PicPeak/picpeak/compare/v3.46.5...v3.46.6) (2026-08-27)
### Bug Fixes
* **images:** fence the capture-date backfill on the file it read ([#1201](https://github.com/PicPeak/picpeak/issues/1201)) ([#1205](https://github.com/PicPeak/picpeak/issues/1205)) ([74ff236](https://github.com/PicPeak/picpeak/commit/74ff236b516b6df00e83d3c314fde552d18605ad))
## [3.46.5](https://github.com/PicPeak/picpeak/compare/v3.46.4...v3.46.5) (2026-08-26)
### Bug Fixes
* **admin:** make "Storage used" report storage used ([#1164](https://github.com/PicPeak/picpeak/issues/1164)) ([#1177](https://github.com/PicPeak/picpeak/issues/1177)) ([ac7ef26](https://github.com/PicPeak/picpeak/commit/ac7ef266dcd0d2b146c9739710c7631e672ef40f))
* **admin:** move the maintenance sweeps' run state into the database ([#1181](https://github.com/PicPeak/picpeak/issues/1181)) ([#1188](https://github.com/PicPeak/picpeak/issues/1188)) ([58ccecc](https://github.com/PicPeak/picpeak/commit/58ccecc304ff308bac3d334553ca5c7ef282eae2))
* **external-media:** one row per external file per event ([#1162](https://github.com/PicPeak/picpeak/issues/1162)) ([#1173](https://github.com/PicPeak/picpeak/issues/1173)) ([e9fcf49](https://github.com/PicPeak/picpeak/commit/e9fcf4960eb998c7e18528d773239f08e42e53bf))
* **external-media:** record captured_at on import and add a backfill (stable) ([#1183](https://github.com/PicPeak/picpeak/issues/1183)) ([7f0ed23](https://github.com/PicPeak/picpeak/commit/7f0ed23ea4c1d9379272b7267da74bc3addc1318))
* **external-media:** store external paths from the media root ([#1163](https://github.com/PicPeak/picpeak/issues/1163)) ([#1174](https://github.com/PicPeak/picpeak/issues/1174)) ([2b1c358](https://github.com/PicPeak/picpeak/commit/2b1c3588aeb26b1503445698efc6ffe4f483e645))
* **gallery:** stop the lightbox loading originals to display a photo ([#1166](https://github.com/PicPeak/picpeak/issues/1166)) ([#1175](https://github.com/PicPeak/picpeak/issues/1175)) ([75facb4](https://github.com/PicPeak/picpeak/commit/75facb4d67d026d312a99252abc7a6c420b864fb))
* **images:** respect EXIF orientation in thumbnails, heroes, previews and watermarks ([#1185](https://github.com/PicPeak/picpeak/issues/1185)) ([#1202](https://github.com/PicPeak/picpeak/issues/1202)) ([5559cd3](https://github.com/PicPeak/picpeak/commit/5559cd333d1a1f0b5ae22ad3361c13b3954ce344))
* **previews:** preserve alpha and animation in the preview tier ([#1176](https://github.com/PicPeak/picpeak/issues/1176)) ([9ffbe2f](https://github.com/PicPeak/picpeak/commit/9ffbe2f98fba53b22200081ae1b6ad2f94003345))
## [3.46.4](https://github.com/PicPeak/picpeak/compare/v3.46.3...v3.46.4) (2026-08-23)
### Bug Fixes
* **gallery:** a guest's own hidden feedback is hidden from them too ([#1150](https://github.com/PicPeak/picpeak/issues/1150)) ([#1157](https://github.com/PicPeak/picpeak/issues/1157)) ([b62cd2c](https://github.com/PicPeak/picpeak/commit/b62cd2c290d54820e8f58d11719d48592a1cd1f1))
* **gallery:** guest filters respect show_feedback_to_guests ([#1044](https://github.com/PicPeak/picpeak/issues/1044)) ([#1156](https://github.com/PicPeak/picpeak/issues/1156)) ([eaa8b41](https://github.com/PicPeak/picpeak/commit/eaa8b41ba323c7eac22e04947fead8e468e9c6c2))
* **gallery:** no Logout button on galleries that don't require a password ([#1149](https://github.com/PicPeak/picpeak/issues/1149)) ([#1154](https://github.com/PicPeak/picpeak/issues/1154)) ([d46397d](https://github.com/PicPeak/picpeak/commit/d46397d92a7648910075fb774b14abf77d893865))
* **scripts:** regenerate-thumbnails resolves external sources through ensureThumbnail ([#1148](https://github.com/PicPeak/picpeak/issues/1148)) ([#1155](https://github.com/PicPeak/picpeak/issues/1155)) ([e46260a](https://github.com/PicPeak/picpeak/commit/e46260ad0799bd411a4158c4cc31d587ba85d4ca))
## [3.46.3](https://github.com/PicPeak/picpeak/compare/v3.46.2...v3.46.3) (2026-08-22)
### Bug Fixes
* **gallery:** a missing file must not take the backend down ([#1128](https://github.com/PicPeak/picpeak/issues/1128)) ([da44f19](https://github.com/PicPeak/picpeak/commit/da44f1947b8317b47271f4f2a98b284b25d752c1))
* **gallery:** give masonry tiles their real shape back ([#1130](https://github.com/PicPeak/picpeak/issues/1130), [#1131](https://github.com/PicPeak/picpeak/issues/1131)) ([d977e3e](https://github.com/PicPeak/picpeak/commit/d977e3e296deeb19c26f1e5a98258eec323d120d))
* **thumbnails:** regenerate external photos, and stop destroying good ones ([#1129](https://github.com/PicPeak/picpeak/issues/1129)) ([dc9e3cd](https://github.com/PicPeak/picpeak/commit/dc9e3cdc5e00ac634f581e8d6b13107fe4839152))
## [3.46.2](https://github.com/PicPeak/picpeak/compare/v3.46.1...v3.46.2) (2026-08-21)
### Bug Fixes
* **ui:** stop iOS Safari zooming in on 14px form fields ([#1114](https://github.com/PicPeak/picpeak/issues/1114)) ([32db1c8](https://github.com/PicPeak/picpeak/commit/32db1c8052d324b09462a17859c7adb5ccfe56e3))
## [3.46.1](https://github.com/PicPeak/picpeak/compare/v3.46.0...v3.46.1) (2026-08-19)
### Bug Fixes
* **preview:** generate lightbox previews for external/reference photos ([#1078](https://github.com/PicPeak/picpeak/issues/1078)) ([#1080](https://github.com/PicPeak/picpeak/issues/1080)) ([6df42ab](https://github.com/PicPeak/picpeak/commit/6df42ab22c705bcb731862db1ed5a27de0a64f30))
## [3.46.0](https://github.com/PicPeak/picpeak/compare/v3.45.16...v3.46.0) (2026-08-16)
### Features
* **backup:** open sqlite → pg .picpeak restore as the supported upgrade direction ([#1041](https://github.com/PicPeak/picpeak/issues/1041)) ([#1059](https://github.com/PicPeak/picpeak/issues/1059)) ([980378a](https://github.com/PicPeak/picpeak/commit/980378a17ba873d0e2f3d76048dacb3b8d7a4eb2))
### Bug Fixes
* **pdf:** RFC 6266-encode Content-Disposition on quote/invoice PDFs ([#1024](https://github.com/PicPeak/picpeak/issues/1024)) ([#1062](https://github.com/PicPeak/picpeak/issues/1062)) ([376311c](https://github.com/PicPeak/picpeak/commit/376311cb9091ff1726e8b383312f22c607dcc8a0))
* **storage:** add S3 client timeouts so a dropped connection can't wedge uploads ([#1049](https://github.com/PicPeak/picpeak/issues/1049)) ([#1054](https://github.com/PicPeak/picpeak/issues/1054)) ([88fa3c5](https://github.com/PicPeak/picpeak/commit/88fa3c52973fa122f8d4e7b21ba1ffc89f9f9c2e))
## [3.45.16](https://github.com/PicPeak/picpeak/compare/v3.45.15...v3.45.16) (2026-08-13)
### Bug Fixes
* **docker:** default NODE_ENV=production so non-compose deploys don't fall back to SQLite ([#1038](https://github.com/PicPeak/picpeak/issues/1038)) ([#1040](https://github.com/PicPeak/picpeak/issues/1040)) ([9003b34](https://github.com/PicPeak/picpeak/commit/9003b34c8a0396cd28906f089aef33f38a23ffb7))
* **events:** make event_date/expires_at nullable on SQLite ([#1029](https://github.com/PicPeak/picpeak/issues/1029)) ([#1036](https://github.com/PicPeak/picpeak/issues/1036)) ([fb3d0b0](https://github.com/PicPeak/picpeak/commit/fb3d0b08b2dc34f7e7dab7da754a3522c52a9eb1))
* **feedback:** persist guest feedback settings, unshadow the guest route ([#1030](https://github.com/PicPeak/picpeak/issues/1030)) ([#1032](https://github.com/PicPeak/picpeak/issues/1032)) ([de459c7](https://github.com/PicPeak/picpeak/commit/de459c701f28532ca53d52773b02de44c9978073))
* **gallery:** coerce SQLite 0/1 booleans in the guest surface ([#1028](https://github.com/PicPeak/picpeak/issues/1028)) ([#1037](https://github.com/PicPeak/picpeak/issues/1037)) ([8b6cd3c](https://github.com/PicPeak/picpeak/commit/8b6cd3c74f2aeb5d38ebfeee04bbc211d6fa2c0c))
## [3.45.15](https://github.com/PicPeak/picpeak/compare/v3.45.14...v3.45.15) (2026-08-10)
### Bug Fixes
* **deps:** bump nanoid and js-yaml out of two HIGH advisories (stable) ([#1014](https://github.com/PicPeak/picpeak/issues/1014)) ([cee0a38](https://github.com/PicPeak/picpeak/commit/cee0a380a6faf2bb0a5c802057ba3140670840d2))
* **slideshow:** stop "no crop" fit letterboxing a pre-cropped frame (stable) ([#1015](https://github.com/PicPeak/picpeak/issues/1015)) ([#1019](https://github.com/PicPeak/picpeak/issues/1019)) ([2bdb120](https://github.com/PicPeak/picpeak/commit/2bdb1204fe61a9b6cd704b35ccfd39efa15ed118))
## [3.45.14](https://github.com/PicPeak/picpeak/compare/v3.45.13...v3.45.14) (2026-08-04)
### Bug Fixes
* **deps:** bump ip-address, brace-expansion and postcss for open CVEs (stable) ([#988](https://github.com/PicPeak/picpeak/issues/988)) ([0fe5792](https://github.com/PicPeak/picpeak/commit/0fe5792a7d30bd948d6430642ca0bec35ddc2ca6))
* **security:** vet the destination project when linking a deal (stable) ([#992](https://github.com/PicPeak/picpeak/issues/992)) ([bf9bd76](https://github.com/PicPeak/picpeak/commit/bf9bd762783a2a675f0a6fcd965addf0f47cec57))
## [3.45.13](https://github.com/PicPeak/picpeak/compare/v3.45.12...v3.45.13) (2026-08-03)
### Bug Fixes
* **auth:** fail closed when the adminAuth roles join errors (stable) ([#975](https://github.com/PicPeak/picpeak/issues/975)) ([cc49f69](https://github.com/PicPeak/picpeak/commit/cc49f6997ac54c3e25d5562721c446b7dac7f074))
* **projects:** stop the cockpit offering email controls the API rejects (stable) ([#977](https://github.com/PicPeak/picpeak/issues/977)) ([2d0e6ab](https://github.com/PicPeak/picpeak/commit/2d0e6ab2dca84cf74c6c6b5c40ecae5c5cde814c))
* **security:** backup/restore hardening — public-dir DB dump, restore path allowlist, gunzip bound, manifest keying (stable) ([#962](https://github.com/PicPeak/picpeak/issues/962)) ([3b88036](https://github.com/PicPeak/picpeak/commit/3b88036fda871b3a1ca2e933c39fa96e37950fe6))
* **security:** bound inbound-mail resources, redact secrets from logs (stable) ([#965](https://github.com/PicPeak/picpeak/issues/965)) ([ccab902](https://github.com/PicPeak/picpeak/commit/ccab9024d4ef2f556169bbca8c6bba4801afe3a0))
* **security:** enforce event ownership on the v1 API surface (GHSA-9697) (stable) ([#963](https://github.com/PicPeak/picpeak/issues/963)) ([4e99897](https://github.com/PicPeak/picpeak/commit/4e9989731390f9c067b048f4fa56ed3bf8ec472d))
* **security:** enforce project ownership on project + project-email routes (stable) ([#966](https://github.com/PicPeak/picpeak/issues/966)) ([fecc18c](https://github.com/PicPeak/picpeak/commit/fecc18cbc837507bf30dd7502786de4e067855a5))
* **security:** escape brand tokens, block tracker redirects, trim logo diagnostic (stable) ([#967](https://github.com/PicPeak/picpeak/issues/967)) ([7f27e67](https://github.com/PicPeak/picpeak/commit/7f27e6771f666a40ec0581dc7702be3a1de8330d))
* **security:** scope dashboard stats/analytics/activity to the caller's events (stable) ([#964](https://github.com/PicPeak/picpeak/issues/964)) ([11f9f58](https://github.com/PicPeak/picpeak/commit/11f9f584ded5f777a61dc2e1e637d478a61ac377))
## [3.45.12](https://github.com/PicPeak/picpeak/compare/v3.45.11...v3.45.12) (2026-08-02)
### Bug Fixes
* **security:** authz/ownership gaps (token binding, auth revocation, feedback/customer ownership, token logging) (stable) ([#951](https://github.com/PicPeak/picpeak/issues/951)) ([5d5db4e](https://github.com/PicPeak/picpeak/commit/5d5db4e766eee23a6678b399cdb9cc449fcea198))
* **security:** neutralize spreadsheet formulas in all CSV/export cell-writers (CSV injection cluster) ([#949](https://github.com/PicPeak/picpeak/issues/949)) ([e5dccf1](https://github.com/PicPeak/picpeak/commit/e5dccf166419bb571b052990aada14801e16ac79))
* **security:** redact gallery share tokens from analytics tracking (GHSA-7m6c) (stable) ([#953](https://github.com/PicPeak/picpeak/issues/953)) ([2c7b5df](https://github.com/PicPeak/picpeak/commit/2c7b5dfd020ac1fb2acdb7667998b2f373ce99d9))
* **security:** unauth share_token leak (HIGH) + restore path-traversal, logo file-read (stable) ([#947](https://github.com/PicPeak/picpeak/issues/947)) ([bfafece](https://github.com/PicPeak/picpeak/commit/bfafecedc755790374565281287b9777ae0f5315))
## [3.45.11](https://github.com/PicPeak/picpeak/compare/v3.45.10...v3.45.11) (2026-08-01)
### Bug Fixes
* **security:** block guest access to hidden/client-only photos across bulk + secure routes (stable) ([#940](https://github.com/PicPeak/picpeak/issues/940)) ([34a7b1c](https://github.com/PicPeak/picpeak/commit/34a7b1c0137cf4cba53f17270f619ce9755c4c98))
* **security:** bump sanitize-html to 2.17.5 (CVE-2026-53606) (stable) ([#938](https://github.com/PicPeak/picpeak/issues/938)) ([7419c68](https://github.com/PicPeak/picpeak/commit/7419c683375d12650c448caa47ffe0444a7e1458))
* **security:** close authorization/ownership gaps (token scope, mass-assignment, category hero, project docs) (stable) ([#944](https://github.com/PicPeak/picpeak/issues/944)) ([2462ba6](https://github.com/PicPeak/picpeak/commit/2462ba6897c93b3f0834d60cc2e6827a45487062))
* **security:** resolve DNS before vetting external hostnames (SSRF cluster) (stable) ([#942](https://github.com/PicPeak/picpeak/issues/942)) ([90275f8](https://github.com/PicPeak/picpeak/commit/90275f88e9af523ba3cc254cef2ca6f48a2a4129))
* **uploads:** prevent cross-photo contamination from filename collisions and non-atomic writes ([#931](https://github.com/PicPeak/picpeak/issues/931)) (stable) ([#934](https://github.com/PicPeak/picpeak/issues/934)) ([fc99e2b](https://github.com/PicPeak/picpeak/commit/fc99e2b233b4a7c81c410f7d557e7dff270437bd))
## [3.45.10](https://github.com/PicPeak/picpeak/compare/v3.45.9...v3.45.10) (2026-07-30)
### Bug Fixes
* **admin:** expose view/download counters in the admin photos list ([#895](https://github.com/PicPeak/picpeak/issues/895) follow-up) (stable) ([#915](https://github.com/PicPeak/picpeak/issues/915)) ([a27d19b](https://github.com/PicPeak/picpeak/commit/a27d19b4d147c33166b47a6bac12b8d1be32daf1))
* **admin:** serve videos with their real MIME type in the admin photo view ([#908](https://github.com/PicPeak/picpeak/issues/908)) (stable) ([#911](https://github.com/PicPeak/picpeak/issues/911)) ([d68d84e](https://github.com/PicPeak/picpeak/commit/d68d84e5c8cfcd123c95e474d2b87d153764f710))
* **admin:** stop marking events expired up to 24h early ([#909](https://github.com/PicPeak/picpeak/issues/909)) (stable) ([#917](https://github.com/PicPeak/picpeak/issues/917)) ([6891769](https://github.com/PicPeak/picpeak/commit/6891769124f77d5af1bbe8eb0932a86753d89e7d))
* **security:** close GHSA-g94x (cross-gallery photo read) + GHSA-pv6w (admin DB export) (stable) ([#925](https://github.com/PicPeak/picpeak/issues/925)) ([60cbda5](https://github.com/PicPeak/picpeak/commit/60cbda5b2228e0bddf5356eae279d9e7916e9ac2))
## [3.45.9](https://github.com/PicPeak/picpeak/compare/v3.45.8...v3.45.9) (2026-07-29)
### Bug Fixes
* **analytics:** make per-photo view/download counters actually count ([#895](https://github.com/PicPeak/picpeak/issues/895)) (stable) ([#905](https://github.com/PicPeak/picpeak/issues/905)) ([90b589a](https://github.com/PicPeak/picpeak/commit/90b589a88e4c56ccac6dba86c48a604f2abcc008))
## [3.45.8](https://github.com/PicPeak/picpeak/compare/v3.45.7...v3.45.8) (2026-07-29)
### Bug Fixes
* **tests:** raise jest timeouts to the 120s convention (stable) ([#902](https://github.com/PicPeak/picpeak/issues/902)) ([962f1d9](https://github.com/PicPeak/picpeak/commit/962f1d95868251ddeb01a5234c8d9bd1d57429e8))
## [3.45.7](https://github.com/PicPeak/picpeak/compare/v3.45.6...v3.45.7) (2026-07-27)
### Bug Fixes
* **security:** close 5 Trivy alerts — postcss/tar bumps + drop npm from the runtime image (stable) ([#879](https://github.com/PicPeak/picpeak/issues/879)) ([d868aac](https://github.com/PicPeak/picpeak/commit/d868aac70300149e77fb9568735b6481f88a644f))
## [3.45.6](https://github.com/PicPeak/picpeak/compare/v3.45.5...v3.45.6) (2026-07-27)
### Bug Fixes
* **backup:** make backup settings actually apply ([#871](https://github.com/PicPeak/picpeak/issues/871)) (stable) ([#875](https://github.com/PicPeak/picpeak/issues/875)) ([a27c705](https://github.com/PicPeak/picpeak/commit/a27c705e392ec1e6b8d8be945127de9ff11d5db0))
## [3.45.5](https://github.com/PicPeak/picpeak/compare/v3.45.4...v3.45.5) (2026-07-26)
### Bug Fixes
* **security:** bump backend deps to close all 14 open Trivy code-scanning alerts (stable) ([#870](https://github.com/PicPeak/picpeak/issues/870)) ([39696d4](https://github.com/PicPeak/picpeak/commit/39696d42fe22478aee1b8d777d8bcfd5ef986fe4))
* **security:** read the password-complexity key the settings UI writes (stable) ([#844](https://github.com/PicPeak/picpeak/issues/844)) ([50f5ca1](https://github.com/PicPeak/picpeak/commit/50f5ca1d5bbe6f03397b3dc66ea8c3bea538466e))
## [3.45.4](https://github.com/PicPeak/picpeak/compare/v3.45.3...v3.45.4) (2026-07-17)
### Bug Fixes
* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) ([8978acd](https://github.com/PicPeak/picpeak/commit/8978acdb492f085fbe92186d9dbddfb35b07b676))
* **events:** accept hero_logo_visible: null on create/update ([#822](https://github.com/PicPeak/picpeak/issues/822)) (stable) ([1cff576](https://github.com/PicPeak/picpeak/commit/1cff576439bce06536f7d308d4ef6d52bdc9bb12))
## [3.45.3](https://github.com/PicPeak/picpeak/compare/v3.45.2...v3.45.3) (2026-07-17)
+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! 🎉
+7 -2
View File
@@ -111,10 +111,15 @@ docker compose up -d
On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
2. Read the **one-time setup token** from the 0600 file the backend writes it to
(it is deliberately *not* printed to the logs — that would leave a live
bootstrap credential in `docker logs`):
```bash
docker compose logs backend | grep -i "setup token"
docker compose exec backend cat /app/data/SETUP_TOKEN
```
It is bind-mounted, so `sudo cat data/SETUP_TOKEN` on the host works too. Only
if that file could not be written does the backend fall back to logging the
token (`docker compose logs backend | grep -i "setup token"`).
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
+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).
+4 -2
View File
@@ -170,10 +170,12 @@ If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your ad
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
2. Read the **one-time setup token** from the 0600 file the backend writes it to
(it is not logged — that would leave a live credential in `docker logs`):
```bash
docker compose logs backend | grep -i "setup token"
docker compose exec backend cat /app/data/SETUP_TOKEN
```
Only if that write fails does the backend log the token instead.
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
## 🌐 Access Methods
+19 -8
View File
@@ -27,6 +27,15 @@ FROM node:22-alpine
WORKDIR /app
# knexfile.js picks its config block by NODE_ENV, and the `development` block
# defaults to sqlite3. Leaving NODE_ENV unset here meant every deployment that
# doesn't go through our compose files — Kubernetes, Helm, plain `docker run` —
# silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD, while
# wait-for-db.sh (shell, reads DB_HOST directly) reported "PostgreSQL is up" in
# the same log. The compose files still override this, so nothing changes for
# compose users. See #1038.
ENV NODE_ENV=production
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
# stage's declaration never reached this stage. Consuming it in the RUN below
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
@@ -37,14 +46,16 @@ ARG CACHEBUST=1
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN echo "cachebust=${CACHEBUST}" && apk upgrade --no-cache
# Upgrade the npm CLI in the final image so its bundled deps are patched
# (sigstore 4.x, tar) — closes CVE-2026-48815 and the older @sigstore/core / tar
# Trivy alerts. Safe here: only the CLI present in the image changes. Runtime
# dependencies come from the builder stage (COPY --from=builder node_modules
# below) and the entrypoint runs node, not npm — so npm 11's install behaviour
# (the reason 10.x was pinned) never executes in this stage. npm 11 needs
# Node >=22.9, satisfied by node:22-alpine.
RUN npm install -g npm@11
# Remove the npm CLI from the final image. Nothing runs npm here: the
# entrypoint is node, runtime deps are COPY'd from the builder stage, and
# wait-for-db.sh invokes the migration runners via node directly. npm's
# bundled node_modules kept tripping Trivy (sigstore, tar 7.5.19,
# brace-expansion 5.0.7 — even npm 12.0.1 still ships the vulnerable
# copies), so shipping no npm ends that alert class instead of chasing
# per-release patches. Note: `docker exec … npm run <script>` no longer
# works in the container — use `node migrations/run-migrations-safe.js`
# and friends instead.
RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/bin/npm /usr/local/bin/npx
# Install dumb-init for proper signal handling, postgresql-client for database
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
@@ -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();
});
});
@@ -40,7 +40,7 @@ jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('GET /api/admin/system-health/backup-coverage', () => {
let db;
@@ -29,7 +29,7 @@ jest.mock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('GET /api/admin/system-health/backup-integrity', () => {
let cleanup;
@@ -0,0 +1,246 @@
/**
* POST /admin/thumbnails/regenerate for external/reference photos (#1129).
*
* STABLE TWIN. Diverges from the main version in one place: stable has no
* responsive ?w= tiers (#1095/#1109), so there is no deleteThumbnailTiers call
* to assert and the "drops the tiers first" test is absent here. Everything
* else — the external rebuild, the thumbnail_path:null contract, video
* skipping, per-event scoping and the superseded-key deletion — is identical.
*
* The route used to resolve every source as `storage/events/active/<path>` and
* `fs.access` it. External and reference rows do not live there — their
* originals sit under `events.external_path` — so every one of them failed the
* check and was counted as an error.
*
* That alone would be inert. What made it destructive is that the tier
* deletion runs FIRST (deliberately, so S3 and external rows are not skipped):
* on a reference install the button dropped every ?w= tier and rebuilt
* nothing, while the UI reported success — the response is sent before the
* background loop starts.
*
* The background work is fired with setImmediate, so every assertion here has
* to wait for it to drain rather than trusting the response.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('admin thumbnail regeneration (#1129)', () => {
let tmpDir; let db; let cleanup; let app; let imageProcessor; let storage;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
await fs.promises.mkdir(process.env.STORAGE_PATH, { 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(),
}));
// One instance, not a fresh object per call — the route and the
// assertions have to be looking at the same mock.
jest.doMock('../../src/services/storage', () => {
const instance = { delete: jest.fn().mockResolvedValue(undefined) };
return { getStorage: () => instance };
});
jest.doMock('../../src/services/imageProcessor', () => ({
ensureThumbnail: jest.fn().mockResolvedValue('thumbnails/thumb_ext1_shot.jpg'),
ensurePreviewImage: jest.fn().mockResolvedValue('previews/p.jpg'),
deletePreviewTiers: jest.fn().mockResolvedValue(undefined),
}));
// bootCrmDb, not run-migrations: the latter calls process.exit(0) on
// success, which ends the jest worker mid-suite.
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
imageProcessor = require('../../src/services/imageProcessor');
storage = require('../../src/services/storage').getStorage();
app = express();
app.use(express.json());
app.use('/admin/thumbnails', require('../../src/routes/adminThumbnails'));
}, 180000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
jest.clearAllMocks();
await db('photos').del();
await db('events').del();
});
async function seedEvent() {
const [row] = await db('events').insert({
slug: 'nas-wedding', event_type: 'wedding', event_name: 'nas',
event_date: '2026-01-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: 'nas-share', expires_at: new Date().toISOString(),
source_mode: 'reference', external_path: 'weddings/2026-08',
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
async function seedPhoto(eventId, overrides = {}) {
const [row] = await db('photos').insert({
event_id: eventId, filename: 'shot.jpg', path: 'nas-wedding/shot.jpg',
type: 'individual', ...overrides,
}).returning('id');
return typeof row === 'object' ? row.id : row;
}
/** The work runs in setImmediate; give it room to finish. */
const drain = () => new Promise((resolve) => setTimeout(resolve, 150));
it('rebuilds the canonical thumbnail for an external photo instead of erroring', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'external',
external_relpath: 'shot.jpg',
thumbnail_path: 'thumbnails/stale.jpg',
});
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
expect(res.status).toBe(200);
await drain();
// The whole bug: this used to be zero calls and one logged
// "Original file not found" per photo.
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
});
it('nulls thumbnail_path so the valid-thumbnail short-circuit cannot skip the rebuild', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'external',
external_relpath: 'shot.jpg',
thumbnail_path: 'thumbnails/still-on-disk.jpg',
});
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
// Without this the endpoint is a no-op whenever the OLD thumbnail is still
// readable — which is the normal case after a settings change, and exactly
// when the admin pressed the button.
const [photoArg] = imageProcessor.ensureThumbnail.mock.calls[0];
expect(photoArg.thumbnail_path).toBeNull();
expect(photoArg.source_origin).toBe('external');
// Carried through so ensureThumbnail can resolve off the mount rather than
// under events/active.
expect(photoArg.external_relpath).toBe('shot.jpg');
});
it('leaves videos alone rather than handing a container file to Sharp', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, { source_origin: 'managed', media_type: 'video', filename: 'clip.mp4' });
await seedPhoto(eventId, { source_origin: 'managed', filename: 'still.jpg' });
const res = await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(res.body.count).toBe(1);
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
expect(imageProcessor.ensureThumbnail.mock.calls[0][0].filename).toBe('still.jpg');
});
/**
* On S3, ensureThumbnail downloads the source to a randomly-named temp file,
* and for non-RAW input withProcessableImage passes no outputBasename — so
* generateThumbnail derives the key from that random name and it differs on
* every run. Nulling thumbnail_path hides the old key from everything that
* would otherwise clean it up, so each regeneration would strand a full
* thumbnail in the bucket, once per photo per run.
*/
describe('superseded canonical renditions', () => {
it('removes the old thumbnail when the key moved', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'managed',
thumbnail_path: 'thumbnails/thumb_OLDRANDOM_shot.jpg',
});
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEWRANDOM_shot.jpg');
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(storage.delete).toHaveBeenCalledWith('thumbnails/thumb_OLDRANDOM_shot.jpg');
});
it('does NOT delete when the key is unchanged — that is the new file', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'managed',
thumbnail_path: 'thumbnails/thumb_stable.jpg',
});
// Local storage resolves to a stable path, so the key is identical.
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_stable.jpg');
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(storage.delete).not.toHaveBeenCalled();
});
it.each([
['a Windows-style legacy path', 'thumbnails\\thumb_ext1_shot.jpg'],
['a leading ./', './thumbnails/thumb_ext1_shot.jpg'],
['a doubled separator', 'thumbnails//thumb_ext1_shot.jpg'],
])('does not delete the file it just wrote when the old path is %s', async (_name, stored) => {
const eventId = await seedEvent();
await seedPhoto(eventId, { source_origin: 'managed', thumbnail_path: stored });
// Both storage backends fold these to the same key, so this is the SAME
// object — deleting it would remove the freshly generated thumbnail and
// leave the row pointing at nothing.
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_ext1_shot.jpg');
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
expect(storage.delete).not.toHaveBeenCalled();
});
it('counts the photo as regenerated even if the old object cannot be removed', async () => {
const eventId = await seedEvent();
await seedPhoto(eventId, {
source_origin: 'managed',
thumbnail_path: 'thumbnails/thumb_OLD.jpg',
});
imageProcessor.ensureThumbnail.mockResolvedValueOnce('thumbnails/thumb_NEW.jpg');
storage.delete.mockRejectedValueOnce(new Error('bucket said no'));
await request(app).post('/admin/thumbnails/regenerate').send({});
await drain();
// Losing the old object is untidy; the regeneration itself succeeded.
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
});
});
it('scopes to one event when asked', async () => {
const a = await seedEvent();
await seedPhoto(a, { source_origin: 'external', external_relpath: 'a.jpg' });
const [b] = await db('events').insert({
slug: 'other', event_type: 'wedding', event_name: 'other', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: 'other-share', expires_at: new Date().toISOString(),
}).returning('id');
await seedPhoto(typeof b === 'object' ? b.id : b, { source_origin: 'managed' });
const res = await request(app).post('/admin/thumbnails/regenerate').send({ eventId: a });
await drain();
expect(res.body.count).toBe(1);
expect(imageProcessor.ensureThumbnail).toHaveBeenCalledTimes(1);
});
});
@@ -23,7 +23,7 @@ const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('backupService — configurable walker (backup_paths)', () => {
let db;
@@ -177,4 +177,203 @@ describe('backupService — configurable walker (backup_paths)', () => {
const filesOn = await backupService.getFilesToBackup(true);
expect(filesOn.map((f) => f.relativePath)).toContain('events/archived/E3/legacy.jpg');
});
// Issue #871 — the "What to Backup" checkboxes were stored but never read.
describe('UI opt-out toggles (issue #871)', () => {
it('unchecking Thumbnails excludes thumbnails/', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({
backup_include_thumbnails: false,
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('thumbnails/E1/thumb.jpg');
});
it('unchecking Photos excludes events/active', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({
backup_include_photos: false,
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('thumbnails/E1/thumb.jpg');
expect(rels).not.toContain('events/active/E1/photo.jpg');
});
it('defaults to including everything when the keys were never saved', async () => {
seedFile('thumbnails/E1/thumb.jpg');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('thumbnails/E1/thumb.jpg');
expect(rels).toContain('events/active/E1/photo.jpg');
});
it("accepts the UI's plural backup_include_archives for the archived gate", async () => {
seedFile('events/archived/E4/archived.jpg');
const files = await backupService.getFilesToBackup({
backup_include_archives: true,
});
expect(files.map((f) => f.relativePath)).toContain('events/archived/E4/archived.jpg');
});
it('the UI plural key beats the migration-seeded singular key', async () => {
// Migration seeds backup_include_archived=true on every install; the
// form only ever writes the plural key, so unchecking Archives must
// win over the stale seeded value.
seedFile('events/archived/E5/archived.jpg');
const files = await backupService.getFilesToBackup({
backup_include_archived: true, // seeded default
backup_include_archives: false, // what the admin actually chose
});
expect(files.map((f) => f.relativePath)).not.toContain('events/archived/E5/archived.jpg');
});
it('rsync gets the de-selected paths and noise filters as --exclude args', async () => {
const excluded = await backupService.resolveExcludedBackupPaths({
backup_include_thumbnails: false,
backup_include_archives: false,
});
expect(excluded.map((r) => r.path)).toEqual(
expect.arrayContaining(['thumbnails', 'events/archived'])
);
const args = backupService.buildRsyncArgs(
{ backup_rsync_host: 'backup.example.com', backup_rsync_path: '/srv/backups' },
excluded.map((r) => `/${r.path}/`)
);
const excludes = args
.map((a, i) => (a === '--exclude' ? args[i + 1] : null))
.filter(Boolean);
expect(excludes).toEqual(expect.arrayContaining([
'.nfs*',
'/thumbnails/',
'/events/archived/',
]));
});
it('rows toggled off via include_in_default also become rsync excludes', async () => {
// The enabled-only loader hides these rows from the walker, but rsync
// syncs the whole storage root, so they must still appear as excludes.
await db('backup_paths').where('path', 'previews').update({
include_in_default: false,
});
const excluded = await backupService.resolveExcludedBackupPaths({});
expect(excluded.map((r) => r.path)).toContain('previews');
});
});
// Issue #871 — .nfs* silly-rename artifacts were uploaded to S3.
it('never backs up filesystem noise (.nfs*, .DS_Store)', async () => {
seedFile('thumbnails/E1/.nfs000000000000006600000008');
seedFile('events/active/E1/.DS_Store');
seedFile('events/active/E1/photo.jpg');
const files = await backupService.getFilesToBackup({});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels.some((r) => r.includes('.nfs'))).toBe(false);
expect(rels.some((r) => r.includes('.DS_Store'))).toBe(false);
});
it('the walker honors backup_exclude_patterns (previously rsync-only)', async () => {
seedFile('events/active/E1/photo.jpg');
seedFile('events/active/E1/scratch.tmp');
const files = await backupService.getFilesToBackup({
backup_exclude_patterns: ['*.tmp'],
});
const rels = files.map((f) => f.relativePath);
expect(rels).toContain('events/active/E1/photo.jpg');
expect(rels).not.toContain('events/active/E1/scratch.tmp');
});
it('glob patterns are literal outside the star (.nfs* must not eat anfs-…)', async () => {
seedFile('events/active/E1/anfs-photo.jpg');
seedFile('events/active/E1/notes-tmp');
const files = await backupService.getFilesToBackup({
backup_exclude_patterns: ['*.tmp'],
});
const rels = files.map((f) => f.relativePath);
// '.nfs*' used to compile to /^.nfs.*$/ whose dot matched any char;
// '*.tmp' used to compile to /^.*.tmp$/ which also matched 'notes-tmp'.
expect(rels).toContain('events/active/E1/anfs-photo.jpg');
expect(rels).toContain('events/active/E1/notes-tmp');
});
// Issue #871 — weekly schedules silently ran daily, and the dashboard's
// "next backup" was a hardcoded "tomorrow 02:00".
describe('schedule resolution + next run (issue #871)', () => {
it('a named label beats the stray default cron the UI used to send', () => {
expect(backupService.resolveScheduleCron({
backup_schedule: 'weekly',
backup_schedule_cron: '0 3 * * *', // old UI default, sent unconditionally
})).toBe('0 3 * * 0');
});
it('custom schedules use the cron field', () => {
expect(backupService.resolveScheduleCron({
backup_schedule: 'custom',
backup_schedule_cron: '15 5 * * 2',
})).toBe('15 5 * * 2');
});
it('falls back to the default daily cron', () => {
expect(backupService.resolveScheduleCron({})).toBe('0 2 * * *');
});
it('getNextScheduledRun is null when backups are disabled', () => {
expect(backupService.getNextScheduledRun(null)).toBeNull();
expect(backupService.getNextScheduledRun({ backup_enabled: false })).toBeNull();
});
it('getNextScheduledRun returns the real next weekly fire time', () => {
const iso = backupService.getNextScheduledRun({
backup_enabled: true,
backup_schedule: 'weekly',
backup_schedule_cron: '0 3 * * *',
});
const next = new Date(iso);
expect(Number.isNaN(next.getTime())).toBe(false);
expect(next.getTime()).toBeGreaterThan(Date.now());
expect(next.getDay()).toBe(0); // Sunday
expect(next.getHours()).toBe(3); // 03:00
});
});
// Issue #871 — "Backup Size: 167.6 TB": file_size_bytes is a bigInteger
// column, node-postgres returns int8 as a string, and the S3 path did
// `backedUpSize += size` — string concatenation.
it('getDatabaseBackupInfo coerces file_size_bytes to a number', async () => {
await db('database_backup_runs').del();
await db('database_backup_runs').insert({
backup_type: 'full',
status: 'completed',
file_path: '/backups/db/dump.sql.gz',
// Simulate the PG int8-as-string driver behaviour (sqlite stores
// whatever it is handed, so the string round-trips).
file_size_bytes: '421988',
started_at: new Date().toISOString(),
completed_at: new Date().toISOString(),
});
const info = await backupService.getDatabaseBackupInfo();
expect(typeof info.size).toBe('number');
expect(info.size).toBe(421988);
});
});
@@ -34,7 +34,7 @@ jest.mock('../../src/services/databaseBackup', () => ({
DatabaseBackupService: class {},
}));
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('backupService — inline DB dump + fail-loud guard', () => {
let db;
@@ -23,7 +23,7 @@ const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('backupService — per-Stage-B-path statistics', () => {
let db;
@@ -14,7 +14,7 @@ const path = require('path');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('backupService — config + file collection + manifest (smoke)', () => {
let db;
@@ -7,7 +7,7 @@
const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('booking cutover — draft invoices on hold', () => {
let db; let cleanup; let adminId; let customerId; let quoteService;
@@ -0,0 +1,227 @@
/**
* Backfilling captured_at on a library imported before #1172.
*
* The point of the endpoint, rather than a migration: it resolves originals
* through resolvePhotoFilePath, which is the only path that reaches an
* external row. The thumbnail regenerator resolves under
* storage/events/active/<photo.path>, which never exists for those (#1129) —
* so it cannot be the model.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
const sharp = require('sharp');
describe('capture date backfill (#1172)', () => {
let tmpDir; let db; let app; let mediaRoot;
const writeJpegWithExif = async (abs, iso) => {
await fs.promises.mkdir(path.dirname(abs), { recursive: true });
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, '0');
const exifDate = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} `
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 9, g: 9, b: 9 } } })
.withExif({ IFD2: { DateTimeOriginal: exifDate } }).jpeg().toFile(abs);
};
const settle = async () => { for (let i = 0; i < 60; i++) { await new Promise((r) => setTimeout(r, 50)); const s = await status(); if (!s.body.isRunning) return s; } throw new Error('backfill did not settle'); };
const status = () => request(app).get('/api/admin/photos/repair-capture-dates/status');
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-capfill-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(mediaRoot, { recursive: true });
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capfill-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotoDimensions'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seed({ relpath, exifIso, writeFile = true, archived = false }) {
await db('photos').del();
await db('events').del();
const [e] = await db('events').insert({
slug: 'capfill', event_type: 'wedding', event_name: 'capfill', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: `capfill-${Math.random()}`, expires_at: new Date().toISOString(),
source_mode: 'reference', external_path: 'trip', is_archived: archived,
}).returning('id');
const eventId = typeof e === 'object' ? e.id : e;
if (writeFile) await writeJpegWithExif(path.join(mediaRoot, 'trip', relpath), exifIso);
const [p] = await db('photos').insert({
event_id: eventId, filename: path.basename(relpath), path: `capfill/${path.basename(relpath)}`,
// Root-relative, as this branch stores it (#1163) — the file lives at
// <mediaRoot>/trip/<relpath>.
type: 'individual', source_origin: 'external', external_relpath: `trip/${relpath}`,
uploaded_at: new Date().toISOString(), captured_at: null,
}).returning('id');
return { eventId, photoId: typeof p === 'object' ? p.id : p };
}
it('fills captured_at for an external photo the thumbnail regenerator cannot reach', async () => {
const { photoId } = await seed({ relpath: 'a.jpg', exifIso: '2026-06-01T09:45:03Z' });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.status).toBe(200);
expect(res.body.count).toBe(1);
const done = await settle();
expect(done.body.lastResult.success).toBe(1);
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeTruthy();
});
it('counts a photo with no EXIF separately from a failure', async () => {
// "The mount is broken" and "these files carry no date" need different
// answers from an operator, so they are not the same number.
await db('photos').del(); await db('events').del();
const { photoId } = await seed({ relpath: 'plain.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
await sharp({ create: { width: 40, height: 30, channels: 3, background: { r: 1, g: 1, b: 1 } } })
.jpeg().toFile(path.join(mediaRoot, 'trip', 'plain.jpg'));
await request(app).post('/api/admin/photos/repair-capture-dates');
const done = await settle();
expect(done.body.lastResult).toMatchObject({ success: 0, noExif: 1, failed: 0 });
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeNull();
});
it('counts an unreachable original as a failure, not as missing EXIF', async () => {
await seed({ relpath: 'gone.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
await request(app).post('/api/admin/photos/repair-capture-dates');
const done = await settle();
expect(done.body.lastResult).toMatchObject({ success: 0, noExif: 0, failed: 1 });
});
it('reports nothing to do once every photo has a date', async () => {
const { photoId } = await seed({ relpath: 'b.jpg', exifIso: '2026-06-02T09:00:00Z' });
await db('photos').where({ id: photoId }).update({ captured_at: new Date().toISOString() });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
expect((await status()).body.withoutCaptureDate).toBe(0);
});
it('skips a watcher-imported video, which carries media_type "image"', async () => {
// fileWatcher.processNewPhoto sets type='video' and a video/* mime but
// never media_type (fileWatcher.js:128-130), so the row keeps the 'image'
// default from migration 048. Filtering on media_type alone queued it every
// run: extractCaptureDate returns null for a video, captured_at stays null,
// and the backlog never cleared.
const { eventId } = await seed({ relpath: 'clip.jpg', exifIso: '2026-06-01T09:45:03Z', writeFile: false });
await db('photos').del();
await db('photos').insert({
event_id: eventId, filename: 'clip.mp4', path: 'capfill/clip.mp4',
type: 'video', media_type: 'image', mime_type: 'video/mp4',
source_origin: 'external', external_relpath: 'trip/clip.mp4',
uploaded_at: new Date().toISOString(), captured_at: null,
});
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
const s = await status();
// And it is not counted as a permanent backlog either.
expect(s.body.total).toBe(0);
expect(s.body.withoutCaptureDate).toBe(0);
});
it('never reports more dated photos than it has photos', async () => {
// Both counts come from one aggregate; as two queries an import committing
// between them produced withCaptureDate > total and a negative backlog.
const { photoId } = await seed({ relpath: 'counted.jpg', exifIso: '2026-06-05T08:00:00Z' });
await db('photos').where({ id: photoId }).update({ captured_at: new Date().toISOString() });
const s = await status();
expect(s.body.total).toBe(1);
expect(s.body.withCaptureDate).toBe(1);
expect(s.body.withoutCaptureDate).toBe(0);
expect(s.body.withoutCaptureDate).toBeGreaterThanOrEqual(0);
});
it('skips archived events instead of failing them on every run', async () => {
// Archiving deletes the originals and keeps the rows, so an archived photo
// can never get a date. Counting it would fail it every pass and leave the
// status endpoint permanently reporting a backlog.
await seed({ relpath: 'archived.jpg', exifIso: '2026-06-04T09:00:00Z', archived: true });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
const s = await status();
expect(s.body.total).toBe(0);
expect(s.body.withoutCaptureDate).toBe(0);
expect(s.body.isRunning).toBe(false);
});
it('does not overwrite a date written while it was running', async () => {
// whereNull on the update: an import or a replacement finishing mid-run has
// already written a better value than this pass would.
const { photoId } = await seed({ relpath: 'c.jpg', exifIso: '2026-06-03T09:00:00Z' });
const claimed = '2020-01-01T00:00:00.000Z';
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(1);
await db('photos').where({ id: photoId }).update({ captured_at: claimed });
const done = await settle();
expect(new Date((await db('photos').where({ id: photoId }).first()).captured_at).toISOString()).toBe(claimed);
expect(done.body.lastResult.success).toBe(0);
// Read but not written, so it is accounted for rather than dropped.
expect(done.body.lastResult.skipped).toBe(1);
});
it('does not date a row whose file was replaced while it was reading (#1201)', async () => {
// replacePhoto swaps a NEW file under an existing row and rewrites
// path/filename (reachable from replace_by_name). The replacement carries
// no date of its own, so captured_at is still NULL and the whereNull guard
// alone would let the previous file's EXIF date land on it. The write is
// fenced on the identity that was read, so the row is skipped instead —
// and not counted as updated either.
const { photoId } = await seed({ relpath: 'orig.jpg', exifIso: '2026-06-03T09:00:00Z' });
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(1);
// Simulate the replacement landing before the loop writes.
await db('photos').where({ id: photoId })
.update({ path: 'capfill/replaced.jpg', filename: 'replaced.jpg' });
const done = await settle();
expect((await db('photos').where({ id: photoId }).first()).captured_at).toBeNull();
expect(done.body.lastResult.success).toBe(0);
// Not an error and not "no EXIF" — the date was found, another writer just
// got there first. It stays in the backlog for the next run.
expect(done.body.lastResult).toMatchObject({ noExif: 0, failed: 0, skipped: 1 });
});
});
@@ -14,7 +14,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Service-level CRM calls cold-require heavy modules (pdfService,
// nodemailer, etc.) on first use; the global 5 s per-test budget is
// too tight for that. Bump it for this file only.
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('discount line items (negative unit_price_minor)', () => {
let db;
@@ -6,7 +6,7 @@
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll.
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('event type slug rename cascade', () => {
let db;
@@ -0,0 +1,161 @@
/**
* External imports must record captured_at (#1172).
*
* Managed uploads get it from photoProcessor, which external media never goes
* through — so every externally imported photo carried captured_at NULL, and
* the gallery's "Date Taken" sort fell back to uploaded_at through its
* COALESCE. On a library imported in two batches that ordered a 12-day trip by
* which folder was imported first: the reporter's first two days landed at
* positions 4204-5296 of 5555.
*
* Driven through the real route against real files carrying real EXIF, because
* the whole question is whether the import reads the file it already has open.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
const sharp = require('sharp');
describe('external import capture dates (#1172)', () => {
let tmpDir; let db; let app; let mediaRoot;
/**
* A real JPEG carrying DateTimeOriginal.
*
* IFD2, not IFD0 — DateTimeOriginal lives in the Exif IFD, and exifr does not
* see it anywhere else (IFD0 takes plain DateTime, which surfaces as
* ModifyDate instead).
*/
const writeJpegWithExif = async (rel, iso) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
const d = new Date(iso);
const pad = (n) => String(n).padStart(2, '0');
const exifDate = `${d.getUTCFullYear()}:${pad(d.getUTCMonth() + 1)}:${pad(d.getUTCDate())} `
+ `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 10, g: 20, b: 30 } } })
.withExif({ IFD2: { DateTimeOriginal: exifDate } })
.jpeg()
.toFile(full);
return full;
};
const writeJpegNoExif = async (rel) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
await sharp({ create: { width: 60, height: 40, channels: 3, background: { r: 200, g: 10, b: 10 } } })
.jpeg().toFile(full);
};
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-capdate-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(mediaRoot, { recursive: true });
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capdate-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
jest.doMock('../../src/services/imageProcessor', () => {
const actual = jest.requireActual('../../src/services/imageProcessor');
return { ...actual, generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'), ensureThumbnail: jest.fn() };
});
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
app = express();
app.use(express.json());
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seedEvent() {
await db('photos').del();
await db('events').del();
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
await fs.promises.mkdir(mediaRoot, { recursive: true });
const [e] = await db('events').insert({
slug: `capdate-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding', event_name: 'capdate', event_date: '2026-01-01',
host_email: 'h@example.com', admin_email: 'a@example.com', password_hash: 'x',
share_link: `capdate-${Math.random()}`, expires_at: new Date().toISOString(),
source_mode: 'reference',
}).returning('id');
return typeof e === 'object' ? e.id : e;
}
const runImport = (eventId, external_path) => request(app)
.post(`/api/admin/external-media/events/${eventId}/import-external`)
.send({ external_path, recursive: true });
it('records the EXIF capture date on import', async () => {
const eventId = await seedEvent();
await writeJpegWithExif('trip/a.jpg', '2026-06-01T09:45:03Z');
await runImport(eventId, 'trip');
const photo = await db('photos').where({ event_id: eventId }).first();
expect(photo.captured_at).toBeTruthy();
// NOT asserted as an absolute instant. EXIF carries a naive wall-clock
// time and exifr resolves it against the HOST timezone, so the stored UTC
// value differs between a CEST developer machine and a UTC runner. What
// this fix is about is that the field is populated and orders correctly;
// that captured_at is not a true instant is a separate, pre-existing
// problem shared with managed uploads (#1172's own footnote).
expect(new Date(photo.captured_at).getUTCFullYear()).toBe(2026);
expect(new Date(photo.captured_at).getUTCMonth()).toBe(5); // June
});
it('imports a photo with no EXIF date rather than failing it', async () => {
// Plenty of sources carry none; that must stay an import, not an error.
const eventId = await seedEvent();
await writeJpegNoExif('trip/plain.jpg');
const res = await runImport(eventId, 'trip');
expect(res.body.imported).toBe(1);
const photo = await db('photos').where({ event_id: eventId }).first();
expect(photo.captured_at).toBeNull();
});
it('orders a two-batch import by capture time, not by batch', async () => {
// The reported shape: the FIRST days of the trip imported second. Sorting
// on COALESCE(captured_at, uploaded_at) put them after the last days,
// because uploaded_at is the import timestamp.
const eventId = await seedEvent();
await writeJpegWithExif('late/day12.jpg', '2026-06-12T10:00:00Z');
await runImport(eventId, 'late');
await writeJpegWithExif('early/day01.jpg', '2026-06-01T10:00:00Z');
await runImport(eventId, 'early');
const rows = await db('photos')
.where({ event_id: eventId })
.orderByRaw('COALESCE(captured_at, uploaded_at) asc')
.select('filename');
expect(rows.map((r) => r.filename)).toEqual(['day01.jpg', 'day12.jpg']);
});
});
@@ -0,0 +1,205 @@
/**
* Two overlapping external imports insert every file twice (#1162).
*
* The route checked for an existing external_relpath and then inserted, with
* an fs.stat and a `sharp().metadata()` read sitting in between. A reporter
* double-clicked a slow import of a 6012-file tree and got 8004 rows.
*
* Both halves of the fix are driven here through the real route:
*
* - the in-flight guard, which turns the second click into a 409 instead of
* a second full walk of the tree;
* - convergence when the guard cannot help (another replica, another
* process), which is the unique index from migration 186 firing and the
* loop counting a skip rather than dying or duplicating.
*
* The second is exercised by inserting a competing row from inside the mocked
* `sharp().metadata()` call — literally inside the window the bug lived in.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('concurrent external imports (#1162)', () => {
let tmpDir; let db; let app; let mediaRoot;
// When set, the mocked sharp metadata read inserts this row first — the
// other run winning the race between our SELECT and our INSERT.
let stealDuringMetadata = null;
let thumbnailDelayMs = 0;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-extdup-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(path.join(mediaRoot, 'nas', 'individual'), { recursive: true });
for (const name of ['a.jpg', 'b.jpg', 'c.jpg']) {
await fs.promises.writeFile(path.join(mediaRoot, 'nas', 'individual', name), 'not-a-real-jpeg');
}
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'extdup-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
// The window. In production this is a real decode of a NAS-hosted file —
// hundreds of milliseconds during which the row we just proved absent can
// appear. Standing in for the other run here makes that deterministic.
jest.doMock('sharp', () => () => ({
metadata: async () => {
if (stealDuringMetadata) {
const { db: liveDb } = require('../../src/database/db');
await liveDb('photos').insert(stealDuringMetadata);
stealDuringMetadata = null;
}
return { width: 100, height: 200 };
},
}));
jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn(async () => {
if (thumbnailDelayMs) await new Promise((r) => setTimeout(r, thumbnailDelayMs));
return 'thumbnails/mock.jpg';
}),
ensureThumbnail: jest.fn(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
app = express();
app.use(express.json());
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seedEvent() {
await db('photos').del();
await db('events').del();
stealDuringMetadata = null;
thumbnailDelayMs = 0;
const [e] = await db('events').insert({
slug: `extdup-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'extdup',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `extdup-${Math.random()}`,
expires_at: new Date().toISOString(),
source_mode: 'reference',
}).returning('id');
return typeof e === 'object' ? e.id : e;
}
const runImport = (eventId) => request(app)
.post(`/api/admin/external-media/events/${eventId}/import-external`)
.send({ external_path: 'nas', recursive: true });
async function relpathCounts(eventId) {
const rows = await db('photos').where({ event_id: eventId }).select('external_relpath');
const counts = new Map();
for (const r of rows) counts.set(r.external_relpath, (counts.get(r.external_relpath) || 0) + 1);
return counts;
}
it('rejects a second import while the first is still running', async () => {
const eventId = await seedEvent();
// Enough to keep the first request inside its loop while the second
// arrives — the "slow import looks hung, so I clicked again" case.
thumbnailDelayMs = 20;
const [first, second] = await Promise.all([runImport(eventId), runImport(eventId)]);
const statuses = [first.status, second.status].sort();
expect(statuses).toEqual([200, 409]);
const rejected = first.status === 409 ? first : second;
expect(rejected.body.error).toMatch(/already running/i);
});
it('leaves exactly one row per file after both runs', async () => {
const eventId = await seedEvent();
thumbnailDelayMs = 20;
await Promise.all([runImport(eventId), runImport(eventId)]);
const counts = await relpathCounts(eventId);
expect(counts.size).toBe(3);
expect([...counts.values()]).toEqual([1, 1, 1]);
});
it('releases the event once the import finishes, so a re-import still works', async () => {
const eventId = await seedEvent();
expect((await runImport(eventId)).status).toBe(200);
// Not 409 — the guard is per run, not a permanent lock on the event.
const second = await runImport(eventId);
expect(second.status).toBe(200);
expect(second.body.imported).toBe(0);
expect(second.body.skipped).toBe(3);
});
it('converges when another writer wins the race mid-file', async () => {
// The guard is in-process, so it cannot see a second replica. This is what
// the unique index is for: the insert bounces, and the file is counted as
// skipped rather than duplicated or lost to a 500.
const eventId = await seedEvent();
stealDuringMetadata = {
event_id: eventId,
filename: 'a.jpg',
path: 'x/a.jpg',
type: 'individual',
source_origin: 'external',
external_relpath: path.join('nas', 'individual', 'a.jpg'),
};
const res = await runImport(eventId);
expect(res.status).toBe(200);
const counts = await relpathCounts(eventId);
expect(counts.get(path.join('nas', 'individual', 'a.jpg'))).toBe(1);
// Two imported by us, one lost to the other writer and reported honestly.
expect(res.body.imported).toBe(2);
expect(res.body.skipped).toBe(1);
});
it('does not let one contended file abort the rest of the import', async () => {
const eventId = await seedEvent();
stealDuringMetadata = {
event_id: eventId,
filename: 'a.jpg',
path: 'x/a.jpg',
type: 'individual',
source_origin: 'external',
external_relpath: path.join('nas', 'individual', 'a.jpg'),
};
await runImport(eventId);
// All three files present — the contended one via the other writer's row.
expect((await relpathCounts(eventId)).size).toBe(3);
});
});
@@ -0,0 +1,175 @@
/**
* Importing a second folder must not move the photos already in the event (#1163).
*
* events.external_path is overwritten by every import, and external_relpath
* used to be stored relative to it — so a second import silently rebased every
* existing row onto the new folder. The reporter had 7547 of 8004 originals
* pointing at files that do not exist, and nothing said so: thumbnails are
* written to local storage during the import while the base path is still
* correct, so the grid carries on rendering.
*
* Driven through the real route and the real resolver, against a real
* directory tree — the failure is entirely about whether a file is where the
* app looks for it.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('a second external import (#1163)', () => {
let tmpDir; let db; let app; let mediaRoot; let resolvePhotoFilePath;
const touch = async (rel) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
await fs.promises.writeFile(full, 'not-a-real-jpeg');
};
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-ext2nd-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(mediaRoot, { recursive: true });
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
process.env.JWT_SECRET = process.env.JWT_SECRET || 'ext2nd-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/middleware/ownership', () => ({
requireEventOwnership: (_req, _res, next) => next(),
}));
jest.doMock('sharp', () => () => ({ metadata: async () => ({ width: 100, height: 200 }) }));
jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn(async () => 'thumbnails/mock.jpg'),
ensureThumbnail: jest.fn(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
({ resolvePhotoFilePath } = require('../../src/services/photoResolver'));
app = express();
app.use(express.json());
app.use('/api/admin/external-media', require('../../src/routes/adminExternalMedia'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
async function seedEvent() {
await db('photos').del();
await db('events').del();
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
await fs.promises.mkdir(mediaRoot, { recursive: true });
const [e] = await db('events').insert({
slug: `ext2nd-${Math.random().toString(36).slice(2, 8)}`,
event_type: 'wedding',
event_name: 'ext2nd',
event_date: '2026-01-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `ext2nd-${Math.random()}`,
expires_at: new Date().toISOString(),
source_mode: 'reference',
}).returning('id');
return typeof e === 'object' ? e.id : e;
}
const runImport = (eventId, external_path) => request(app)
.post(`/api/admin/external-media/events/${eventId}/import-external`)
.send({ external_path, recursive: true });
/** Where the app would go looking for this photo's original, right now. */
async function resolved(eventId, filename) {
const event = await db('events').where({ id: eventId }).first();
const photo = await db('photos').where({ event_id: eventId, filename }).first();
return resolvePhotoFilePath(event, photo);
}
it('stores paths relative to the media root, not to the imported folder', async () => {
const eventId = await seedEvent();
await touch('Trip/Leknes/old.jpg');
await runImport(eventId, 'Trip');
const photo = await db('photos').where({ event_id: eventId }).first();
expect(photo.external_relpath).toBe(path.join('Trip', 'Leknes', 'old.jpg'));
});
it('leaves the first folders originals reachable after a second import', async () => {
const eventId = await seedEvent();
await touch('Trip/Leknes/old.jpg');
await touch('Trip/Sub/new.jpg');
await runImport(eventId, 'Trip');
const before = await resolved(eventId, 'old.jpg');
await runImport(eventId, 'Trip/Sub');
const after = await resolved(eventId, 'old.jpg');
// The regression: `after` used to be <root>/Trip/Sub/Leknes/old.jpg.
expect(after).toBe(before);
expect(fs.existsSync(after)).toBe(true);
});
it('every original in the event is still on disk afterwards', async () => {
const eventId = await seedEvent();
await touch('Trip/Leknes/a.jpg');
await touch('Trip/Leknes/b.jpg');
await touch('Trip/Sub/c.jpg');
await runImport(eventId, 'Trip');
await runImport(eventId, 'Trip/Sub');
const event = await db('events').where({ id: eventId }).first();
const photos = await db('photos').where({ event_id: eventId });
expect(photos).toHaveLength(3);
for (const photo of photos) {
expect(fs.existsSync(resolvePhotoFilePath(event, photo))).toBe(true);
}
});
it('does not re-insert a file the first import already took', async () => {
// The dedupe check compares stored paths, so it has to be comparing the
// same shape the insert writes.
const eventId = await seedEvent();
await touch('Trip/Sub/c.jpg');
await runImport(eventId, 'Trip');
const second = await runImport(eventId, 'Trip/Sub');
expect(second.body.imported).toBe(0);
expect(second.body.skipped).toBe(1);
expect(await db('photos').where({ event_id: eventId }).count('* as c').first()).toEqual({ c: 1 });
});
it('resolves a subfolder that repeats its parents name', async () => {
// The old resolver stripped the relpath's first segment when it matched the
// base path's last one, which broke exactly this layout.
const eventId = await seedEvent();
await touch('Trip/Trip/x.jpg');
await runImport(eventId, 'Trip');
const event = await db('events').where({ id: eventId }).first();
const photo = await db('photos').where({ event_id: eventId }).first();
expect(resolvePhotoFilePath(event, photo)).toBe(path.join(mediaRoot, 'Trip', 'Trip', 'x.jpg'));
});
});
@@ -0,0 +1,121 @@
/**
* PostgreSQL integration test for the external-path fold (#1163).
*
* Gated the same way as picpeakRestorePg: runs only when PICPEAK_PG_TEST_URL
* points at a throwaway Postgres DB, e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_fold_test" \
* npx jest __tests__/integration/externalRelpathFoldPg.test.js
*
* This exists because of a defect SQLite could not have caught. The two-pass
* rewrite parks each row on a temporary value, and that value was first written
* with a leading NUL. SQLite stores NUL in TEXT without complaint; Postgres
* rejects it outright ("invalid byte sequence for encoding UTF8"), so migration
* 187 would have rolled back on exactly the installs needing the repair — and
* only on the engine most of them run.
*
* The staging value is therefore an engine-level contract, not an
* implementation detail, and it is pinned here on the engine that constrains it.
*/
const knex = require('knex');
const fs = require('fs');
const os = require('os');
const path = require('path');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('external relpath fold on Postgres', () => {
let pgDb; let mediaRoot; let fold;
const touch = async (rel, bytes) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
await fs.promises.writeFile(full, Buffer.alloc(bytes));
return bytes;
};
beforeAll(async () => {
mediaRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-foldpg-'));
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
jest.resetModules();
({ foldExternalRelpaths: fold } = require('../../src/services/externalRelpathFold'));
pgDb = knex({ client: 'pg', connection: PG_URL });
}, 60000);
afterAll(async () => {
if (pgDb) await pgDb.destroy();
await fs.promises.rm(mediaRoot, { recursive: true, force: true }).catch(() => {});
delete process.env.EXTERNAL_MEDIA_ROOT;
});
beforeEach(async () => {
await pgDb.raw('DROP TABLE IF EXISTS photos, events, app_settings CASCADE');
await pgDb.schema.createTable('events', (t) => {
t.increments('id');
t.text('external_path');
});
await pgDb.schema.createTable('photos', (t) => {
t.increments('id');
t.integer('event_id');
t.text('external_relpath');
t.bigInteger('size_bytes');
t.string('source_origin').defaultTo('managed');
});
await pgDb.schema.createTable('app_settings', (t) => {
t.increments('id');
t.string('setting_key');
t.text('setting_value');
t.string('setting_type');
t.string('updated_at');
});
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
await fs.promises.mkdir(mediaRoot, { recursive: true });
});
const relpaths = async () =>
(await pgDb('photos').orderBy('id').select('external_relpath')).map((r) => r.external_relpath);
it('completes the two-pass repair that a NUL staging value would abort', async () => {
// The exact shape that forces staging: `photo.jpg` repairs up to
// `Trip/photo.jpg`, while the row already holding `Trip/photo.jpg` folds
// deeper. Every final value is distinct, but a final value equals another
// row's current one, so the rewrite has to park first.
const a = await touch('Trip/photo.jpg', 11);
const b = await touch('Trip/Sub/Trip/photo.jpg', 22);
await pgDb('events').insert({ id: 1, external_path: 'Trip/Sub' });
await pgDb('photos').insert([
{ event_id: 1, external_relpath: 'photo.jpg', size_bytes: a, source_origin: 'external' },
{ event_id: 1, external_relpath: 'Trip/photo.jpg', size_bytes: b, source_origin: 'external' },
]);
await fold(pgDb);
expect(await relpaths()).toEqual(['Trip/photo.jpg', 'Trip/Sub/Trip/photo.jpg']);
});
it('leaves no staging value behind', async () => {
await touch('Trip/a.jpg', 8);
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
await fold(pgDb);
const rows = await relpaths();
expect(rows).toEqual(['Trip/a.jpg']);
expect(rows.some((r) => r.includes('staging'))).toBe(false);
});
it('folds and marks in one transaction', async () => {
await touch('Trip/a.jpg', 8);
await pgDb('events').insert({ id: 1, external_path: 'Trip' });
await pgDb('photos').insert({ event_id: 1, external_relpath: 'a.jpg', size_bytes: 8, source_origin: 'external' });
await fold(pgDb);
// Second run is a no-op: the marker committed with the rewrites.
await fold(pgDb);
expect(await relpaths()).toEqual(['Trip/a.jpg']);
});
});
@@ -0,0 +1,216 @@
/**
* Guest filters must respect show_feedback_to_guests (#1044 follow-up).
*
* Every filter token on /photos is an OR of two halves: what THIS viewer
* marked, and what ANYONE marked. The response fields built from the second
* half — like_count, comment_count — are all gated on
* show_feedback_to_guests. The FILTER was not.
*
* So with the setting off, the numbers were hidden but `?filter=liked` still
* returned exactly the photos other people had liked: the same information as
* a set instead of a count, one token at a time. These tests pin the gate on
* every token, and pin that the viewer's own half is never gated — filtering
* by what you yourself marked is yours to do regardless.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'filter-visibility-secret';
const SLUG = 'filter-visibility-event';
const ME = 'guest-me-identifier';
const SOMEONE_ELSE = 'guest-other-identifier';
describe('guest filters and show_feedback_to_guests (#1044)', () => {
let db;
let cleanup;
let app;
let eventId;
let mine;
let theirs;
let myGuestRowId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const setVisibility = (visible) => db('event_feedback_settings')
.where({ event_id: eventId })
.update({ show_feedback_to_guests: visible });
// A real verified guest, which is how the viewer's own feedback is actually
// identified — NOT the `guest_id` query parameter the frontend invents.
const guestToken = () => jwt.sign(
{ type: 'guest', guestId: myGuestRowId, eventId },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const filter = async (token, { as = 'me', claimGuestId } = {}) => {
const req = request(app)
.get(`/api/gallery/${SLUG}/photos`)
.query({ filter: token, ...(claimGuestId ? { guest_id: claimGuestId } : {}) })
.set('Authorization', `Bearer ${galleryToken()}`);
if (as === 'me') req.set('x-guest-token', guestToken());
const res = await req;
expect(res.status).toBe(200);
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
return (photos || []).map((p) => p.id).sort((a, b) => a - b);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Filter Visibility',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'filter-visibility-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const addPhoto = async (name) => {
const p = await db('photos').insert({
event_id: eventId,
filename: name,
path: `events/filter/${name}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
return p[0]?.id ?? p[0];
};
mine = await addPhoto('mine.jpg');
theirs = await addPhoto('theirs.jpg');
await db('event_feedback_settings').insert({
event_id: eventId,
feedback_enabled: true,
allow_likes: true,
allow_comments: true,
allow_ratings: true,
allow_favorites: true,
moderate_comments: false,
show_feedback_to_guests: true,
});
const guestRow = await db('gallery_guests').insert({
event_id: eventId,
name: 'Me',
identifier: ME,
created_at: new Date().toISOString(),
last_seen_at: new Date().toISOString(),
is_deleted: false,
}).returning('id');
myGuestRowId = guestRow[0]?.id ?? guestRow[0];
const feedback = (photoId, who, type, extra = {}) => db('photo_feedback').insert({
photo_id: photoId,
event_id: eventId,
guest_identifier: who,
// Submission links to the per-person guest row when one is present, and
// that is the column the viewer's own half resolves through.
guest_id: who === ME ? myGuestRowId : null,
feedback_type: type,
is_approved: true,
is_hidden: false,
created_at: new Date().toISOString(),
...extra,
});
// Everything on `theirs` belongs to somebody else; `mine` is this viewer's.
await feedback(mine, ME, 'like');
await feedback(theirs, SOMEONE_ELSE, 'like');
await feedback(theirs, SOMEONE_ELSE, 'favorite');
await feedback(theirs, SOMEONE_ELSE, 'comment', { comment_text: 'lovely' });
await feedback(theirs, SOMEONE_ELSE, 'rating', { rating: 5 });
// The denormalized counters the aggregate half of the filter reads.
await db('photos').where('id', theirs).update({
like_count: 1, favorite_count: 1, comment_count: 1, average_rating: 5,
});
await db('photos').where('id', mine).update({ like_count: 1 });
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
describe('with feedback visible to guests', () => {
beforeAll(() => setVisibility(true));
it('shows other people\'s marks through every token, as before', async () => {
expect(await filter('liked')).toEqual([mine, theirs].sort((a, b) => a - b));
expect(await filter('favorited')).toEqual([theirs]);
expect(await filter('rated')).toEqual([theirs]);
expect(await filter('commented')).toEqual([theirs]);
});
});
describe('with feedback hidden from guests', () => {
beforeAll(() => setVisibility(false));
it('stops every token from selecting on other people\'s marks', async () => {
// `theirs` is the photo only other guests marked. It must not come back
// through any token — a filter that selects on hidden feedback reports
// that feedback just as surely as a count would.
expect(await filter('favorited')).toEqual([]);
expect(await filter('rated')).toEqual([]);
expect(await filter('commented')).toEqual([]);
});
it('still filters by what the viewer marked themselves', async () => {
// The viewer's own half is never gated: this is their own action, and
// hiding it would break "show me the ones I liked" for no privacy gain.
expect(await filter('liked')).toEqual([mine]);
});
it('drops the viewer\'s own feedback once an admin hides it', async () => {
// Moderation has to reach the filter too. getPhotoFeedback excludes
// hidden rows for the guest's OWN feedback, so a photo matching here
// would come back with nothing visible on it to explain why.
await db('photo_feedback')
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
.update({ is_hidden: true });
expect(await filter('liked')).toEqual([]);
await db('photo_feedback')
.where({ photo_id: mine, guest_id: myGuestRowId, feedback_type: 'like' })
.update({ is_hidden: false });
expect(await filter('liked')).toEqual([mine]);
});
it('ignores a guest_id supplied by the caller', async () => {
// The own-half is resolved from the request identity. If it honoured the
// query string instead, anyone holding another guest's identifier could
// read that guest's hidden memberships one token at a time — straight
// back through the gate this file exists to pin.
expect(await filter('favorited', { claimGuestId: SOMEONE_ELSE })).toEqual([]);
// And an anonymous caller claiming to be me gets nothing of mine.
expect(await filter('liked', { as: 'anon', claimGuestId: ME })).toEqual([]);
});
});
});
@@ -17,7 +17,7 @@ const request = require('supertest');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
jest.setTimeout(120000);
let db; let cleanup; let service; let app;
@@ -19,7 +19,7 @@
*/
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
jest.setTimeout(120000);
let db; let cleanup; let service; let adminId;
@@ -0,0 +1,245 @@
/**
* Hidden feedback, seen from the guest who left it (#1150).
*
* Everything in the system treats a hidden row as absent: getPhotoFeedback
* drops it even for the guest's own feedback, the /photos filters drop it, and
* updatePhotoFeedbackStats does not count it. One place disagreed — the
* per-viewer `is_liked` heart — so a like the photographer had hidden still
* showed as liked on a photo whose like_count was zero. (The `my_color_label`
* badge has the same shape on main; colour labels are not on this branch.)
*
* Making those two agree exposes the second half: the duplicate check that
* powers like/favorite toggling did NOT skip hidden rows, so the now-empty
* heart, when clicked, found the hidden row and toggled it OFF. The click
* appeared to do nothing and it took two more to get back to a filled heart.
*
* Hiding a non-comment is deliberate, not an accident of the raw route: #839
* and #1044 both ship it, with tests asserting that a hidden reaction or
* colour label stops counting. So the fix is to make hidden mean absent
* consistently — not to stop admins hiding these.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-feedback-secret';
const SLUG = 'hidden-own-feedback';
const ME = 'guest-me-identifier';
describe('a guest\'s own hidden feedback (#1150)', () => {
let db; let cleanup; let app; let feedbackService;
let eventId; let photoId; let myGuestRowId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const guestToken = () => jwt.sign(
{ type: 'guest', guestId: myGuestRowId, eventId },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const getPhoto = async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`)
.set('x-guest-token', guestToken());
expect(res.status).toBe(200);
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
return (photos || []).find((p) => p.id === photoId);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
feedbackService = require('../../src/services/feedbackService');
const [ev] = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Hidden Own Feedback',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'hidden-own-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
const [p] = await db('photos').insert({
event_id: eventId, filename: 'shot.jpg', path: 'events/hidden/shot.jpg',
type: 'individual', uploaded_at: new Date().toISOString(),
}).returning('id');
photoId = typeof p === 'object' ? p.id : p;
const [g] = await db('gallery_guests').insert({
event_id: eventId, name: 'Me', identifier: ME,
created_at: new Date().toISOString(), last_seen_at: new Date().toISOString(),
is_deleted: false,
}).returning('id');
myGuestRowId = typeof g === 'object' ? g.id : g;
await db('event_feedback_settings').insert({
event_id: eventId, feedback_enabled: true, allow_likes: true,
moderate_comments: false,
show_feedback_to_guests: true,
});
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/gallery', require('../../src/routes/galleryFeedback'));
}, 180000);
afterAll(async () => { if (cleanup) await cleanup(); });
const like = () => db('photo_feedback').insert({
photo_id: photoId, event_id: eventId, guest_identifier: ME,
guest_id: myGuestRowId, feedback_type: 'like',
is_approved: true, is_hidden: false, created_at: new Date().toISOString(),
});
beforeEach(async () => {
await db('photo_feedback').where({ photo_id: photoId }).del();
await db('photos').where('id', photoId).update({ like_count: 0 });
});
describe('the read surfaces agree with each other', () => {
it('un-fills the heart once the like is hidden', async () => {
await like();
await feedbackService.updatePhotoFeedbackStats(photoId);
expect((await getPhoto()).is_liked).toBe(true);
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
await feedbackService.updatePhotoFeedbackStats(photoId);
const photo = await getPhoto();
// like_count already ignored hidden rows, so the heart was the only
// thing still claiming this photo was liked.
expect(photo.like_count).toBe(0);
expect(photo.is_liked).toBe(false);
});
});
describe('and every other surface agrees', () => {
it('keeps a hidden like out of /my-feedback', async () => {
await like();
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
const res = await request(app)
.get(`/api/gallery/${SLUG}/my-feedback`)
.set('Authorization', `Bearer ${galleryToken()}`)
.set('x-guest-token', guestToken());
expect(res.status).toBe(200);
// In guest identity mode the Liked/Favorited/Rated chips and their
// filters are built from THIS array, not from is_liked — so a hidden
// like left an empty heart while the chip still counted it.
expect(res.body.filter((f) => f.feedback_type === 'like')).toHaveLength(0);
});
it('does not count a hidden row against the guest cap', async () => {
await db('event_feedback_settings')
.where({ event_id: eventId }).update({ max_likes_per_guest: 1 });
await like();
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
// The hidden row is room, not an occupant: the guest sees an empty
// heart, and meeting that click with limit_reached leaves the control
// dead until they un-like something they can still see.
const result = await feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
});
expect(result.limit_reached).toBeUndefined();
await db('event_feedback_settings')
.where({ event_id: eventId }).update({ max_likes_per_guest: null });
});
it('leaves other anonymous rows alone when there is no identity to scope by', async () => {
// With neither guest_id nor guest_identifier the collapse scope degrades
// to `guest_identifier IS NULL` — every identifier-less row on the
// photo, i.e. other people's.
const anon = (extra) => ({
photo_id: photoId, event_id: eventId, feedback_type: 'like',
is_approved: true, created_at: new Date().toISOString(), ...extra,
});
const [h] = await db('photo_feedback').insert(anon({ is_hidden: true })).returning('id');
const hiddenId = typeof h === 'object' ? h.id : h;
await db('photo_feedback').insert(anon({ is_hidden: false }));
await db('photo_feedback').insert(anon({ is_hidden: false }));
await feedbackService.moderateFeedback(hiddenId, 'approve', 1);
expect(await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false }))
.toHaveLength(3);
});
it('collapses the replacement when an admin unhides the original', async () => {
await like();
const original = await db('photo_feedback').where({ photo_id: photoId }).first();
await db('photo_feedback').where('id', original.id).update({ is_hidden: true });
await feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like', guest_identifier: ME, guest_id: myGuestRowId,
});
expect(await db('photo_feedback').where({ photo_id: photoId })).toHaveLength(2);
await feedbackService.moderateFeedback(original.id, 'approve', 1);
// Two visible rows for one guest would double-count in the tallies and
// need two toggles to clear, since each deletes a single row.
const visible = await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
expect(visible).toHaveLength(1);
expect(visible[0].id).toBe(original.id);
});
});
describe('and clicking still works afterwards', () => {
it('re-liking creates a fresh row instead of toggling the hidden one off', async () => {
await like();
await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like' })
.update({ is_hidden: true });
// What the guest sees is an empty heart, so this is an ADD.
const result = await feedbackService.submitFeedback(photoId, eventId, {
feedback_type: 'like',
guest_identifier: ME,
guest_id: myGuestRowId,
});
// Before this, the duplicate check found the hidden row and deleted it —
// `removed: true` — so the click did nothing visible and the moderation
// was silently undone.
expect(result.removed).toBeUndefined();
const visible = await db('photo_feedback')
.where({ photo_id: photoId, feedback_type: 'like', is_hidden: false });
expect(visible).toHaveLength(1);
expect((await getPhoto()).is_liked).toBe(true);
});
});
});
@@ -13,7 +13,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// Service-level CRM calls cold-require heavy modules (pdfService, nodemailer)
// on first use; bump the budget for this file.
jest.setTimeout(60000);
jest.setTimeout(120000);
describe('incoming-invoice categorise / re-bill chain', () => {
let db;
@@ -32,7 +32,7 @@ jest.mock('../../src/services/restoreService', () => ({
},
}));
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('installFromBackupBoot', () => {
let db;
@@ -13,7 +13,7 @@ const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
jest.setTimeout(120000);
let db;
let cleanup;
@@ -0,0 +1,233 @@
/**
* Shared run state for the maintenance sweeps (#1181).
*
* The behaviour that matters here cannot be observed from one process holding
* a module-level flag, which is exactly why the flag moved into the database.
* A second replica is simulated the only way that is honest in a single-process
* test: by asserting on the shared row itself, and by driving claim() twice —
* a second caller getting null is precisely what a second replica gets.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('maintenance job state (#1181)', () => {
let tmpDir; let db; let app; let jobs;
const dimStatus = () => request(app).get('/api/admin/photos/repair-dimensions/status');
const capStatus = () => request(app).get('/api/admin/photos/repair-capture-dates/status');
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mjs-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'db.sqlite');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mjs-secret';
jest.resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => { req.admin = { id: 1, username: 'tester', roleName: 'admin' }; next(); },
}));
jest.doMock('../../src/middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db } = await require('./helpers/crmDb').bootCrmDb());
jobs = require('../../src/services/maintenanceJobState');
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotoDimensions'));
}, 180000);
afterAll(async () => {
if (db) await db.destroy?.();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
await db('maintenance_jobs').update({
is_running: false, started_at: null, heartbeat_at: null, finished_at: null, last_result: null, owner: null, claim_token: null,
});
});
test('the lease table is kept out of .picpeak archives', () => {
// It is live state, not data. An archive taken mid-sweep would otherwise
// carry is_running = true and a claim token owned by a process on the
// SOURCE install; restored inside the staleness window, the target reports
// the job as running and refuses new POSTs with no runner to release it.
// The importer filters on this same set, so archives written before the
// exclusion are skipped on restore too.
const { EXCLUDED_TABLES } = require('../../src/services/picpeakExportService');
expect(EXCLUDED_TABLES.has('maintenance_jobs')).toBe(true);
});
test('the migration seeds a row for each job', async () => {
const names = await db('maintenance_jobs').pluck('job_name');
expect(names.sort()).toEqual(['photo_capture_date_backfill', 'photo_dimension_repair']);
});
test('a second claim is refused while the first is alive', async () => {
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
// What a second replica's POST does. Nothing about the first claim lives in
// this process, so this is the same question the other replica asks.
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
});
test('the two jobs claim independently', async () => {
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
expect(await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL)).toEqual(expect.any(String));
});
test('each claim gets a distinct token', async () => {
const first = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
await jobs.release(jobs.JOB_DIMENSION_REPAIR, first);
const second = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
// Same process, same pid — so an owner string would have collided here and
// the fencing below would be worthless.
expect(second).not.toBe(first);
});
test('a claim whose heartbeat has gone quiet can be taken over', async () => {
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
// The replica holding it was killed: no release, no further heartbeats.
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toEqual(expect.any(String));
});
test('a superseded runner cannot renew its lease', async () => {
const oldToken = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
const newToken = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
expect(newToken).toEqual(expect.any(String));
// The old runner is still alive and mid-loop. Its renewal must tell it so,
// which is what makes the route loop stop instead of running alongside the
// new owner.
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, oldToken)).toBe(false);
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, newToken)).toBe(true);
});
test('a superseded runner cannot release the new owner\'s claim', async () => {
const oldToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_CAPTURE_DATE_BACKFILL }).update({ heartbeat_at: longAgo });
const newToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
// The old runner finishes late and tries to write its result. Unfenced,
// this cleared is_running under the new owner and let a THIRD sweep start.
expect(await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, oldToken, { success: 999, noExif: 0, failed: 0 })).toBe(false);
const state = await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL);
expect(state.isRunning).toBe(true);
expect(state.lastResult).toBeNull();
// And the row is still the new owner's to release.
expect(await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, newToken, { success: 1, noExif: 0, failed: 0 })).toBe(true);
});
test('a stale run reads as not running, so the button comes back', async () => {
await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(true);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
// is_running is still true in the row — nothing released it — but a status
// poll must not leave the operator staring at a job that cannot finish.
expect((await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).first()).is_running).toBeTruthy();
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(false);
});
test('a heartbeat keeps a long run claimed', async () => {
const token = await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
const longAgo = new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString();
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ heartbeat_at: longAgo });
expect(await jobs.heartbeat(jobs.JOB_DIMENSION_REPAIR, token)).toBe(true);
expect(await jobs.claim(jobs.JOB_DIMENSION_REPAIR)).toBeNull();
expect((await jobs.read(jobs.JOB_DIMENSION_REPAIR)).isRunning).toBe(true);
});
test('release stores the result and read gives it back parsed', async () => {
const token = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, token, { success: 3, noExif: 2, failed: 1 });
const state = await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL);
expect(state.isRunning).toBe(false);
expect(state.lastResult).toEqual({ success: 3, noExif: 2, failed: 1 });
});
test('releasing without a result keeps the previous run visible', async () => {
const first = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, first, { success: 7, noExif: 0, failed: 0 });
// The "nothing to do" path: claimed, found no candidates, released. It must
// not blank the numbers the last real run reported.
const second = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, second);
expect((await jobs.read(jobs.JOB_CAPTURE_DATE_BACKFILL)).lastResult).toEqual({ success: 7, noExif: 0, failed: 0 });
});
test('a malformed result does not take the status endpoint down', async () => {
await db('maintenance_jobs').where({ job_name: jobs.JOB_DIMENSION_REPAIR }).update({ last_result: 'not json' });
const state = await jobs.read(jobs.JOB_DIMENSION_REPAIR);
expect(state.lastResult).toBeNull();
expect(state.isRunning).toBe(false);
});
test('both status endpoints report the shared row, not process memory', async () => {
await jobs.claim(jobs.JOB_DIMENSION_REPAIR);
const capToken = await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
await jobs.release(jobs.JOB_CAPTURE_DATE_BACKFILL, capToken, { success: 1, noExif: 0, failed: 0 });
// Written straight to the row, exactly as another replica would have.
const dim = await dimStatus();
expect(dim.status).toBe(200);
expect(dim.body.isRunning).toBe(true);
const cap = await capStatus();
expect(cap.status).toBe(200);
expect(cap.body.isRunning).toBe(false);
expect(cap.body.lastResult).toEqual({ success: 1, noExif: 0, failed: 0 });
});
test('a POST is refused while another replica holds the claim', async () => {
// The claim was taken by "another replica" — this process knows nothing
// about it beyond the row.
await jobs.claim(jobs.JOB_CAPTURE_DATE_BACKFILL);
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.status).toBe(409);
const dimRes = await request(app).post('/api/admin/photos/repair-dimensions');
// The other job is untouched by that claim, so it is free to start.
expect(dimRes.status).toBe(200);
});
test('the no-op path releases the claim it took', async () => {
// No photos at all, so both endpoints take their "nothing to do" exit.
await db('photos').del();
const res = await request(app).post('/api/admin/photos/repair-capture-dates');
expect(res.body.count).toBe(0);
const row = await db('maintenance_jobs').where({ job_name: jobs.JOB_CAPTURE_DATE_BACKFILL }).first();
expect(row.is_running).toBeFalsy();
// ...and a second POST is therefore accepted rather than 409ing forever.
expect((await request(app).post('/api/admin/photos/repair-capture-dates')).status).toBe(200);
});
});
@@ -0,0 +1,102 @@
/**
* PostgreSQL checks for the shared maintenance-job state (#1181).
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway database, e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_mjs_test" \
* npx jest __tests__/integration/maintenanceJobStatePg.test.js
*
* What SQLite cannot answer: the claim leans on comparing a `timestamp` column
* against an ISO-8601 string, and on an UPDATE ... WHERE guard being atomic
* under real concurrent connections. SQLite compares those strings
* lexicographically and serialises writes anyway, so it would pass either way —
* exactly the shape of divergence that has bitten this repo before.
*/
const knex = require('knex');
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('maintenance job state on Postgres', () => {
let pgDb;
let jobs;
const JOB = 'photo_dimension_repair';
beforeAll(async () => {
pgDb = knex({ client: 'pg', connection: PG_URL, pool: { min: 0, max: 10 } });
await pgDb.raw('DROP TABLE IF EXISTS maintenance_jobs');
await require('../../migrations/core/179_maintenance_job_state').up(pgDb);
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
jobs = require('../../src/services/maintenanceJobState');
}, 60000);
afterAll(async () => {
jest.dontMock('../../src/database/db');
if (pgDb) await pgDb.destroy();
});
beforeEach(async () => {
await pgDb('maintenance_jobs').update({
is_running: false, started_at: null, heartbeat_at: null, finished_at: null, last_result: null, owner: null, claim_token: null,
});
});
test('the ISO-string cutoff really compares as a timestamp, not as text', async () => {
expect(await jobs.claim(JOB)).toEqual(expect.any(String));
expect(await jobs.claim(JOB)).toBeNull();
await pgDb('maintenance_jobs').where({ job_name: JOB })
.update({ heartbeat_at: new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString() });
// If Postgres had rejected or mis-cast the ISO string this would either
// throw or never match.
expect(await jobs.claim(JOB)).toEqual(expect.any(String));
const row = await pgDb('maintenance_jobs').where({ job_name: JOB }).first();
expect(row.heartbeat_at).toBeInstanceOf(Date);
});
test('concurrent claims on real connections produce exactly one winner', async () => {
// The whole point of the conditional UPDATE. Ten connections race; nine
// must lose. SQLite cannot demonstrate this — it serialises writers.
const results = await Promise.all(Array.from({ length: 10 }, () => jobs.claim(JOB)));
expect(results.filter(Boolean)).toHaveLength(1);
// ...and the winner holds a token nobody else can forge.
expect(results.find(Boolean)).toEqual(expect.any(String));
});
test('a released job can be re-claimed exactly once again', async () => {
const token = await jobs.claim(JOB);
await jobs.release(JOB, token, { success: 2, failed: 0 });
const results = await Promise.all(Array.from({ length: 5 }, () => jobs.claim(JOB)));
expect(results.filter(Boolean)).toHaveLength(1);
expect((await jobs.read(JOB)).lastResult).toEqual({ success: 2, failed: 0 });
});
test('a superseded runner is fenced out on real Postgres', async () => {
const oldToken = await jobs.claim(JOB);
await pgDb('maintenance_jobs').where({ job_name: JOB })
.update({ heartbeat_at: new Date(Date.now() - jobs.DEFAULT_STALE_MS - 60000).toISOString() });
const newToken = await jobs.claim(JOB);
expect(await jobs.heartbeat(JOB, oldToken)).toBe(false);
expect(await jobs.release(JOB, oldToken, { success: 999, failed: 0 })).toBe(false);
// The new owner still holds it, with its result unwritten.
expect((await jobs.read(JOB)).isRunning).toBe(true);
expect(await jobs.release(JOB, newToken, { success: 4, failed: 0 })).toBe(true);
});
test('read() reports a live claim as running and a stale one as not', async () => {
await jobs.claim(JOB);
expect((await jobs.read(JOB)).isRunning).toBe(true);
await pgDb('maintenance_jobs').where({ job_name: JOB })
.update({ heartbeat_at: new Date(Date.now() - jobs.DEFAULT_STALE_MS - 1000).toISOString() });
expect((await jobs.read(JOB)).isRunning).toBe(false);
});
});
@@ -0,0 +1,197 @@
'use strict';
/**
* Cross-engine .picpeak restore policy (#1041): a SQLite archive restored onto
* a PostgreSQL instance — the official small-install → full-stack upgrade
* path — now allowed by validateManifest's direction rule instead of the
* former CLI-only allowEngineSwitch flag. The coercion engine itself
* (typedColumnsFor / epochToIso / coerceForTargetEngine) landed with #1039;
* these tests pin the direction policy and the coercion's cross-engine
* value-correctness.
*
* Ungated: validateManifest direction rules and the pure coercion units.
* The reverse direction (pg backup onto a sqlite instance) staying blocked is
* pinned by picpeakRoundtrip.test.js, which runs on the real sqlite harness.
*
* Gated on PICPEAK_PG_TEST_URL (same contract as picpeakRestorePg.test.js):
* sqlite-shaped NDJSON rows land in real Postgres with correct stored VALUES,
* not just row counts, e.g.
* PICPEAK_PG_TEST_URL="postgres://picpeak:pw@127.0.0.1:7102/picpeak_xengine_test" \
* npx jest __tests__/integration/picpeakCrossEngine.test.js
*/
const knexLib = require('knex');
describe('validateManifest cross-engine direction (pg target)', () => {
let validateManifest;
beforeAll(() => {
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
// validateManifest wraps its knex_migrations lookup in try/catch — a
// throwing stub simply skips the forward-only check, which is not under
// test here.
jest.doMock('../../src/database/db', () => ({ db: () => { throw new Error('stub'); } }));
({ validateManifest } = require('../../src/services/picpeakImportService'));
});
afterAll(() => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
jest.resetModules();
});
it('allows a sqlite backup onto a pg instance (upgrade direction)', async () => {
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'sqlite' }, tables: {},
});
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
});
it('still allows same-engine pg → pg', async () => {
const blockers = await validateManifest({
kind: 'picpeak-backup', format: 1, database: { engine: 'pg' }, tables: {},
});
expect(blockers.filter((b) => /engine/i.test(b))).toHaveLength(0);
});
});
describe('epochToIso (landed with #1039)', () => {
let epochToIso;
beforeAll(() => {
jest.resetModules();
({ epochToIso } = require('../../src/services/picpeakImportService'));
});
it('converts epoch milliseconds', () => {
expect(epochToIso(1723400000000)).toBe('2024-08-11T18:13:20.000Z');
});
it('converts epoch SECONDS to the same instant, not January 1970', () => {
expect(epochToIso(1723400000)).toBe('2024-08-11T18:13:20.000Z');
});
it('converts numeric strings', () => {
expect(epochToIso('1723400000000')).toBe('2024-08-11T18:13:20.000Z');
});
it('passes non-numeric values through untouched', () => {
expect(epochToIso('2026-08-12 10:00:00')).toBe('2026-08-12 10:00:00');
});
});
describe('coerceForTargetEngine on sqlite-shaped rows', () => {
let coerceForTargetEngine;
beforeAll(() => {
jest.resetModules();
({ coerceForTargetEngine } = require('../../src/services/picpeakImportService'));
});
const types = { timestamps: ['created_at', 'expires_at'], booleans: ['is_active'] };
it('coerces 0/1 booleans and epoch timestamps, leaves date strings alone', () => {
const [row] = coerceForTargetEngine(
[{ id: 1, is_active: 1, created_at: 1723400000000, expires_at: '2026-09-01 12:00:00' }],
types
);
expect(row.is_active).toBe(true);
expect(row.created_at).toBe('2024-08-11T18:13:20.000Z');
expect(row.expires_at).toBe('2026-09-01 12:00:00'); // pg parses this natively
});
it('coerces falsy variants and passes null/empty through', () => {
const [row] = coerceForTargetEngine(
[{ is_active: 0, created_at: null, expires_at: '' }],
types
);
expect(row.is_active).toBe(false);
expect(row.created_at).toBeNull();
expect(row.expires_at).toBe('');
});
});
// ── Real-Postgres integration (gated) ────────────────────────────────────────
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
const maybe = PG_URL ? describe : describe.skip;
maybe('sqlite-shaped rows land correctly in real Postgres', () => {
let pgDb;
let svc;
beforeAll(async () => {
pgDb = knexLib({ client: 'pg', connection: PG_URL });
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
await pgDb.schema.createTable('xengine_events', (t) => {
t.increments('id');
t.string('slug');
t.boolean('is_active').defaultTo(true);
t.boolean('allow_downloads').defaultTo(true);
t.timestamp('created_at');
t.timestamp('expires_at');
});
await pgDb.schema.createTable('xengine_settings', (t) => {
t.increments('id');
t.string('setting_key').notNullable().unique();
t.jsonb('setting_value');
});
jest.resetModules();
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
svc = require('../../src/services/picpeakImportService');
});
afterAll(async () => {
jest.dontMock('../../src/database/db');
jest.dontMock('../../knexfile');
if (pgDb) {
await pgDb.raw('DROP TABLE IF EXISTS xengine_events, xengine_settings CASCADE');
await pgDb.destroy();
}
});
it('typedColumnsFor classifies boolean and timestamp columns via columnInfo()', async () => {
const types = await svc.typedColumnsFor(pgDb, 'xengine_events');
expect(types.booleans.sort()).toEqual(['allow_downloads', 'is_active']);
expect(types.timestamps.sort()).toEqual(['created_at', 'expires_at']);
});
it('inserts a sqlite archive row (0/1 booleans, epoch dates, json text) with correct stored values', async () => {
// Exactly what a sqlite-created .picpeak carries: integers for booleans,
// epoch numbers for #485-shape timestamps (ms here, seconds covered by the
// epochToIso unit), a "YYYY-MM-DD HH:MM:SS" string for clean ones, and
// json columns as TEXT (the crossEngine path skips serialiseJsonColumns —
// the text is already what pg wants).
const epoch = 1723400000000;
const eventRows = [
{ id: 1, slug: 'wedding', is_active: 1, allow_downloads: 0, created_at: epoch, expires_at: '2026-09-01 12:00:00' },
];
const settingRows = [{ id: 1, setting_key: 'brand', setting_value: '{"name":"PicPeak","dark":true}' }];
await pgDb.transaction(async (trx) => {
const evTypes = await svc.typedColumnsFor(trx, 'xengine_events');
await trx.batchInsert('xengine_events', svc.coerceForTargetEngine(eventRows, evTypes), 100);
const stTypes = await svc.typedColumnsFor(trx, 'xengine_settings');
await trx.batchInsert('xengine_settings', svc.coerceForTargetEngine(settingRows, stTypes), 100);
});
const ev = await pgDb('xengine_events').where({ id: 1 }).first();
expect(ev.is_active).toBe(true); // 1 → true, not backwards (#1028 class)
expect(ev.allow_downloads).toBe(false); // 0 → false
expect(new Date(ev.created_at).getTime()).toBe(epoch);
expect(new Date(ev.expires_at).toISOString().slice(0, 10)).toBe('2026-09-01');
const st = await pgDb('xengine_settings').where({ id: 1 }).first();
// jsonb parsed back by the driver — value intact, no double encoding.
expect(st.setting_value).toEqual({ name: 'PicPeak', dark: true });
});
it('id sequence works after explicit-id insert + resync (next natural insert)', async () => {
await svc.resyncSequences(['xengine_events']);
const [next] = await pgDb('xengine_events')
.insert({ slug: 'fresh', is_active: true })
.returning('id');
expect(Number(next.id || next)).toBe(2);
});
});
@@ -21,7 +21,7 @@ beforeAll(async () => {
({ db, cleanup, tmpDir } = await bootCrmDb());
process.env.STORAGE_PATH = tmpDir; // isolate file collection to the temp dir
({ createPicpeak } = require('../../src/services/picpeakExportService'));
}, 60000);
}, 120000);
afterAll(async () => {
await cleanup();
@@ -28,7 +28,7 @@ beforeAll(async () => {
({ importFromPicpeak, validateManifest } = require('../../src/services/picpeakImportService'));
const role = await db('roles').where({ name: 'super_admin' }).first();
superAdminRoleId = role.id;
}, 60000);
}, 120000);
afterAll(async () => {
await cleanup();
@@ -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');
});
});
@@ -0,0 +1,260 @@
/**
* scripts/regenerate-thumbnails.js against external photos (#1148).
*
* The same defect #1129 fixed in the admin route, still standing in the CLI
* fallback: the script resolved every source as
* `storage/events/active/<photo.path>` and fs.access'd it. External and
* reference rows do not live there — their originals sit under
* `events.external_path` — so every one failed the check and was counted as an
* error. On an install where all photos are external the script did nothing at
* all, while reporting one error per photo.
*
* Driven against a REAL file on a REAL external mount with the real
* imageProcessor, not a mock: the whole point is that the source resolves off
* the mount, and a mocked ensureThumbnail would assert nothing about that.
*
* Responsive tiers (#1095/#1109) do not exist on this branch, so the tier
* backfill in the main twin has nothing to port. Everything else does.
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const sharp = require('sharp');
const { execFile } = require('child_process');
describe('regenerate-thumbnails script (#1148)', () => {
let tmpDir; let db; let cleanup; let regenerateThumbnails;
let eventId; let externalPhotoId; let videoPhotoId; let watcherVideoId; let repairPhotoId;
let vanishingPhotoId;
let externalRoot;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-script-'));
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'test.db');
process.env.STORAGE_PATH = path.join(tmpDir, 'storage');
// External sources are sandboxed under EXTERNAL_MEDIA_ROOT. Rows carry a
// path relative to that root (#1163), so the 'wedding/' prefix on each
// external_relpath below is the event folder, not decoration.
process.env.EXTERNAL_MEDIA_ROOT = path.join(tmpDir, 'media');
externalRoot = path.join(process.env.EXTERNAL_MEDIA_ROOT, 'wedding');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
await fs.promises.mkdir(process.env.STORAGE_PATH, { recursive: true });
await fs.promises.mkdir(externalRoot, { recursive: true });
jest.resetModules();
({ db, cleanup } = await require('./helpers/crmDb').bootCrmDb());
// A real image on the external mount — never under events/active.
await sharp({
create: { width: 1200, height: 800, channels: 3, background: { r: 10, g: 90, b: 160 } },
}).jpeg().toFile(path.join(externalRoot, 'shot.jpg'));
const [ev] = await db('events').insert({
slug: 'regen-script-event',
event_type: 'wedding',
event_name: 'Regen Script',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: '/gallery/regen-script-event/share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
source_mode: 'reference',
external_path: 'wedding',
created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
const [p] = await db('photos').insert({
event_id: eventId,
filename: 'shot.jpg',
// `path` is what the old script joined onto events/active. Left
// populated on purpose: the fix must ignore it for an external row.
path: 'regen-script-event/shot.jpg',
type: 'individual',
source_origin: 'external',
external_relpath: 'wedding/shot.jpg',
uploaded_at: new Date().toISOString(),
}).returning('id');
externalPhotoId = typeof p === 'object' ? p.id : p;
const [v] = await db('photos').insert({
event_id: eventId,
filename: 'clip.mp4',
path: 'regen-script-event/clip.mp4',
type: 'individual',
media_type: 'video',
mime_type: 'video/mp4',
source_origin: 'external',
external_relpath: 'wedding/clip.mp4',
uploaded_at: new Date().toISOString(),
}).returning('id');
videoPhotoId = typeof v === 'object' ? v.id : v;
// How fileWatcher.processNewPhoto actually writes a video: `type` and
// `mime_type` set, media_type left to its 'image' default. A media_type-only
// filter lets this through and hands the container to Sharp.
//
// The file has to EXIST, otherwise the row fails resolution and looks
// skipped for the wrong reason — the bug is Sharp being handed a video, not
// a missing source. Real MP4 header bytes, no image in sight.
await fs.promises.writeFile(
path.join(externalRoot, 'watched.mp4'),
Buffer.from('00000018667479706d70343200000000', 'hex')
);
const [wv] = await db('photos').insert({
event_id: eventId,
filename: 'watched.mp4',
path: 'regen-script-event/watched.mp4',
type: 'video',
mime_type: 'video/mp4',
source_origin: 'external',
external_relpath: 'wedding/watched.mp4',
uploaded_at: new Date().toISOString(),
}).returning('id');
watcherVideoId = typeof wv === 'object' ? wv.id : wv;
expect((await db('photos').where('id', watcherVideoId).first()).media_type).not.toBe('video');
// A photo whose thumbnail_path points at something that is no longer there.
await sharp({
create: { width: 900, height: 600, channels: 3, background: { r: 200, g: 40, b: 40 } },
}).jpeg().toFile(path.join(externalRoot, 'repair.jpg'));
const [rp] = await db('photos').insert({
event_id: eventId,
filename: 'repair.jpg',
path: 'regen-script-event/repair.jpg',
type: 'individual',
thumbnail_path: 'thumbnails/thumb_ext_missing_repair.jpg',
source_origin: 'external',
external_relpath: 'wedding/repair.jpg',
uploaded_at: new Date().toISOString(),
}).returning('id');
repairPhotoId = typeof rp === 'object' ? rp.id : rp;
// A photo whose source is not on the mount at all — an unavailable mount,
// which is the failure an operator most needs to hear about.
const [vp] = await db('photos').insert({
event_id: eventId,
filename: 'missing.jpg',
path: 'regen-script-event/missing.jpg',
type: 'individual',
source_origin: 'external',
external_relpath: 'missing.jpg',
uploaded_at: new Date().toISOString(),
}).returning('id');
vanishingPhotoId = typeof vp === 'object' ? vp.id : vp;
({ regenerateThumbnails } = require('../../scripts/regenerate-thumbnails'));
}, 180000);
afterAll(async () => {
if (cleanup) await cleanup();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
it('builds a thumbnail for an external photo instead of erroring on events/active', async () => {
// The location the old script computed and fs.access'd. Nothing is there,
// which is the whole defect — it is not where an external original lives.
// (The old script cannot be driven from a test directly: it had no export
// and ran on require, calling process.exit. Making it importable is part
// of this fix.)
const legacyPath = path.join(process.env.STORAGE_PATH, 'events/active', 'regen-script-event/shot.jpg');
expect(fs.existsSync(legacyPath)).toBe(false);
const result = await regenerateThumbnails(eventId);
// The old script reported an error for this photo and wrote nothing.
// The unresolvable row fails; the external photo and the repair row build.
expect(result.errorCount).toBe(1);
expect(result.successCount).toBe(2);
const row = await db('photos').where('id', externalPhotoId).first();
expect(row.thumbnail_path).toBeTruthy();
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
expect(fs.existsSync(onDisk)).toBe(true);
// Named per-photo so two events referencing one NAS basename cannot
// clobber each other — the property ensureThumbnail owns and the reason
// the script must not build this name itself.
expect(path.basename(row.thumbnail_path)).toContain(`ext${externalPhotoId}_`);
});
it('leaves videos alone', async () => {
// A video thumbnail is a poster frame from videoProcessor; handing the
// container to Sharp produced one error per video row.
const row = await db('photos').where('id', videoPhotoId).first();
expect(row.thumbnail_path).toBeFalsy();
});
it('leaves a watcher-imported video alone, which carries no media_type', async () => {
// fileWatcher writes type + mime_type and lets media_type default to
// 'image', so filtering on media_type alone still fed these to Sharp. The
// signal is errorCount: the images are already done by now, so the only
// NEW thing that could fail this run is a video reaching Sharp. One error
// is the deliberately unresolvable row; two would be the video.
const result = await regenerateThumbnails(eventId);
expect(result.errorCount).toBe(1);
const row = await db('photos').where('id', watcherVideoId).first();
expect(row.thumbnail_path).toBeFalsy();
});
it('is idempotent — a second run skips instead of rebuilding', async () => {
const before = await db('photos').where('id', externalPhotoId).first();
const result = await regenerateThumbnails(eventId);
expect(result.errorCount).toBe(1);
expect(result.successCount).toBe(0);
expect(result.skipCount).toBe(2);
const after = await db('photos').where('id', externalPhotoId).first();
expect(after.thumbnail_path).toBe(before.thumbnail_path);
});
it('counts a repaired thumbnail as generated, not skipped', async () => {
// Both images are valid at this point. Destroy ONE thumbnail object while
// leaving thumbnail_path pointing at it — the corrupt/missing case.
const row = await db('photos').where('id', repairPhotoId).first();
const onDisk = path.join(process.env.STORAGE_PATH, row.thumbnail_path);
await fs.promises.rm(onDisk);
const result = await regenerateThumbnails(eventId);
// On local and external storage the rebuilt key is identical, so inferring
// "skipped" from an unchanged path reports this repair as already valid —
// the one number an operator running this is actually reading.
expect(result.successCount).toBe(1);
expect(result.skipCount).toBe(1);
expect(result.errorCount).toBe(1);
expect(fs.existsSync(onDisk)).toBe(true);
});
/** Run the CLI the way cron does, and hand back its exit status. */
const runCli = (args = []) => new Promise((resolve) => {
execFile(
process.execPath,
[path.join(__dirname, '..', '..', 'scripts', 'regenerate-thumbnails.js'), ...args],
{ env: { ...process.env }, cwd: path.join(__dirname, '..', '..') },
(error, stdout, stderr) => resolve({ code: error?.code ?? 0, stdout, stderr })
);
});
it('exits nonzero when a photo could not be built', async () => {
// Exit status is the only thing a cron job reads, and `missing.jpg` has no
// source on the mount.
const failed = await runCli([String(eventId)]);
expect(failed.code).toBe(1);
expect(failed.stderr).toContain('completed with failures');
}, 120000);
it('exits zero when every photo resolves', async () => {
// Drop the unresolvable row: a clean run must not cry wolf at automation.
await db('photos').where('id', vanishingPhotoId).del();
const ok = await runCli([String(eventId)]);
expect(ok.code).toBe(0);
expect(ok.stdout).toContain('Script completed successfully');
}, 120000);
});
@@ -13,14 +13,14 @@ const { execFileSync } = require('child_process');
const { bootCrmDb } = require('./helpers/crmDb');
jest.setTimeout(60000);
jest.setTimeout(120000);
let db;
let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 60000);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -183,22 +183,24 @@ describe('restoreService — PG branch scope contract (PR #596 review)', () => {
expect(window).toMatch(/was_successful:\s*true/);
});
it('npm run migrate:safe is invoked after the replay in restore()', () => {
it('the safe migration runner is invoked after the replay in restore()', () => {
// Contract from PR #596 round 4: backups taken on older picpeak
// versions must restore COMPLETELY on a newer image — even if new
// migrations have been added since the backup was taken. The
// restore() flow shells out to `npm run migrate:safe` AFTER the
// restore() flow shells out to the safe migration runner AFTER the
// operator-meta replay so the schema catches up to the running
// code WITHIN the restore boundary (not on the next container
// restart).
// restart). Invoked as `node migrations/run-migrations-safe.js` —
// the runtime image ships no npm, so the former `npm run
// migrate:safe` would ENOENT into the non-fatal catch.
//
// Contract:
// 1. A `migrate:safe` shell-out exists somewhere in restoreService
// 1. A run-migrations-safe shell-out exists somewhere in restoreService
// 2. It sits AFTER the replay drain — verification → replay →
// migrations is the documented order
// 3. It does NOT sit inside performDatabaseRestore (must run
// against the reinit'd pool from the parent restore())
const migrateLine = findFirst(/['"]migrate:safe['"]/);
const migrateLine = findFirst(/run-migrations-safe\.js/);
expect(migrateLine).toBeGreaterThan(0);
const replayLine = findLast(/this\.preservedMetaSnapshot\.length\s*>\s*0/);
@@ -27,7 +27,7 @@ beforeAll(async () => {
setupService = require('../../src/services/setupService');
({ getAppSetting, upsertAppSetting } = require('../../src/utils/appSettings'));
app = buildRouteApp('/api/setup', require('../../src/routes/setup'));
}, 60000);
}, 120000);
afterAll(async () => {
await cleanup();
@@ -0,0 +1,150 @@
/**
* Slideshow photo source (#1015).
*
* The bug: with `lightbox_preview_enabled` off (the default), /photos emitted
* `preview_url: null`, so the slideshow's `preview_url || hero_url || url`
* chain fell through to `hero_url` — a 1920x1080 `fit: 'cover'` centre crop
* meant for gallery header banners. With the "Black Bars (No crop)" fit the
* show then letterboxed an already-cropped frame: portrait photos lost their
* top and bottom and the setting looked broken.
*
* The contract pinned here: `slideshow_url` points at the aspect-preserved
* preview tier and is emitted for image photos REGARDLESS of the lightbox
* toggle, so the slideshow never has a reason to reach for `hero_url`.
* `preview_url` itself must stay gated — the lightbox opt-in is unchanged.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-src-test-secret';
const SLUG = 'slideshow-source-event';
describe('Slideshow photo source (#1015)', () => {
let db;
let cleanup;
let app;
let eventId;
let imagePhotoId;
let videoPhotoId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const setLightboxPreview = async (on) => {
await db('app_settings').where({ setting_key: 'lightbox_preview_enabled' }).del();
await db('app_settings').insert({
setting_key: 'lightbox_preview_enabled',
setting_value: JSON.stringify(on),
setting_type: 'general',
updated_at: new Date().toISOString(),
});
};
const fetchPhotos = async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`)
.expect(200);
return res.body.photos;
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Slideshow Source Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'slideshow-source-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const img = await db('photos').insert({
event_id: eventId,
filename: 'portrait.jpg',
path: 'events/slideshow-source/portrait.jpg',
type: 'individual',
mime_type: 'image/jpeg',
uploaded_at: new Date().toISOString(),
}).returning('id');
imagePhotoId = img[0]?.id ?? img[0];
const vid = await db('photos').insert({
event_id: eventId,
filename: 'clip.mp4',
path: 'events/slideshow-source/clip.mp4',
type: 'individual',
media_type: 'video',
mime_type: 'video/mp4',
uploaded_at: new Date().toISOString(),
}).returning('id');
videoPhotoId = vid[0]?.id ?? vid[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();
});
it('emits slideshow_url for image photos even when lightbox previews are OFF', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
expect(image.slideshow_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
// The regression: this is what used to be null, pushing the show to hero.
expect(image.preview_url).toBeNull();
});
it('leaves preview_url gated so the lightbox opt-in is unchanged', async () => {
await setLightboxPreview(true);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
expect(image.preview_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
expect(image.slideshow_url).toBe(image.preview_url);
});
it('never points the slideshow at the cover-cropped hero tier', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
// hero_url still ships (the gallery header uses it) — it just must not be
// what the slideshow resolves to.
expect(image.hero_url).toBe(`/api/gallery/${SLUG}/hero/${imagePhotoId}`);
expect(image.slideshow_url).not.toBe(image.hero_url);
});
it('emits slideshow_url: null for videos, which have no preview tier', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const video = photos.find((p) => p.id === videoPhotoId);
expect(video.slideshow_url).toBeNull();
});
});
@@ -0,0 +1,107 @@
/**
* The admin photo list's category filter, and the value it answers to (#1211).
*
* The frontend used to send `category_id=0` for "Uncategorized". This route
* skips `'0'` outright — the guard reads `category_id !== '0'` — so no
* condition was applied and the whole event came back. Four lines below that
* guard sits the branch that does the work, keyed on the literal
* `uncategorized`, which nothing was sending.
*
* Reported in #1209 by someone trying to isolate a few thousand uncategorised
* imports. The frontend half is fixed in PhotoFilters; this pins the backend
* half of the same contract, because the failure mode was the two ends
* disagreeing about a string and neither one being wrong on its own.
*/
const request = require('supertest');
const express = require('express');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
describe('admin photo list — uncategorized filter (#1211)', () => {
let db; let cleanup; let app;
let eventId; let categoryId;
let uncategorisedIds; let categorisedId;
const list = async (query = '') => {
const res = await request(app).get(`/api/admin/events/${eventId}/photos${query}`);
expect(res.status).toBe(200);
const photos = Array.isArray(res.body) ? res.body : res.body.photos;
return (photos || []).map((p) => p.id).sort((a, b) => a - b);
};
beforeAll(async () => {
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(),
}));
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn(),
}));
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const [ev] = await db('events').insert({
slug: 'uncat-filter', event_type: 'wedding', event_name: 'Uncat Filter',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: '/gallery/uncat-filter/share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_at: new Date().toISOString(),
}).returning('id');
eventId = typeof ev === 'object' ? ev.id : ev;
const [cat] = await db('photo_categories')
.insert({ name: 'Ceremony', slug: 'ceremony', event_id: eventId })
.returning('id');
categoryId = typeof cat === 'object' ? cat.id : cat;
const insertPhoto = async (filename, category) => {
const [p] = await db('photos').insert({
event_id: eventId, filename, path: `events/uncat/${filename}`,
type: 'individual', category_id: category,
uploaded_at: new Date().toISOString(),
}).returning('id');
return typeof p === 'object' ? p.id : p;
};
// Two with no category — the shape a plugin upload leaves behind — and one
// filed properly, so a filter that does nothing is visibly different from
// a filter that works.
uncategorisedIds = [await insertPhoto('a.jpg', null), await insertPhoto('b.jpg', null)];
categorisedId = await insertPhoto('c.jpg', categoryId);
uncategorisedIds.sort((a, b) => a - b);
app = express();
app.use(express.json());
app.use('/api/admin/events', require('../../src/routes/adminPhotos'));
}, 180000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('returns only the photos with no category', async () => {
expect(await list('?category_id=uncategorized')).toEqual(uncategorisedIds);
});
it('returns everything when no category filter is given', async () => {
expect(await list()).toEqual([...uncategorisedIds, categorisedId].sort((a, b) => a - b));
});
it('still filters by a real category id', async () => {
expect(await list(`?category_id=${categoryId}`)).toEqual([categorisedId]);
});
it('treats 0 as no filter at all', async () => {
// Pinning the behaviour that made the bug silent rather than loud: '0' is
// not "uncategorized" and never was, it simply falls through the guard. A
// future change that made 0 mean uncategorized here would be fine too —
// but it must be a decision, not an accident, and this test forces it.
expect(await list('?category_id=0')).toEqual([...uncategorisedIds, categorisedId].sort((a, b) => a - b));
});
});
@@ -10,7 +10,7 @@ const { bootCrmDb } = require('./helpers/crmDb');
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
jest.setTimeout(120000);
let db;
let cleanup;
@@ -9,7 +9,7 @@ const {
// bootCrmDb runs the full core-migration set in beforeAll; under full-suite
// parallel load on a small CI runner that can exceed the 5s default. Match the
// other migration-heavy CRM suites (discountLineItems, incomingInvoiceRebill).
jest.setTimeout(30000);
jest.setTimeout(120000);
let db;
let cleanup;
@@ -0,0 +1,111 @@
/**
* The roles-join fallback in adminAuth fabricates `role_name = 'super_admin'`
* to keep existing sessions working across the RBAC upgrade window. The catch
* around it used to be unconditional, so ANY transient database failure —
* connection reset, deadlock, statement timeout, pool exhaustion — took the
* same branch and handed the caller super_admin for the duration of the fault.
*
* `roleName` is the sole discriminator for every ownership check (ownership.js,
* adminProjects, adminUsers, adminApiTokens, projectService, ...), so that
* inverted the whole authorization model rather than failing the request.
* Issue #968. Same treatment apiTokenAuth already got for the v1 surface.
*/
const jwt = require('jsonwebtoken');
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) }));
jest.mock('../../src/utils/logger', () => ({ warn: jest.fn(), error: jest.fn(), debug: jest.fn(), info: jest.fn() }));
// The joined query throws whatever the test stages; the role-less fallback
// query (no .leftJoin) always succeeds, which is what made the original bug
// reachable — it is the cheaper single-table read.
// `mock`-prefixed so jest's module-factory hoisting allows the reference.
let mockJoinError = null;
const mockAdminRow = { id: 7, username: 'scoped', email: 's@example.com', password_changed_at: null };
jest.mock('../../src/database/db', () => ({
db: () => ({
_joined: false,
leftJoin() { this._joined = true; return this; },
where() { return this; },
select() { return this; },
first() {
if (this._joined && mockJoinError) return Promise.reject(mockJoinError);
return Promise.resolve({ ...mockAdminRow });
},
}),
}));
const { adminAuth } = require('../../src/middleware/auth');
const SECRET = 'test-secret-for-admin-auth-fallback';
function makeReq() {
const token = jwt.sign(
{ id: mockAdminRow.id, type: 'admin' },
SECRET,
{ algorithm: 'HS256', issuer: 'picpeak-auth' },
);
return { headers: { authorization: `Bearer ${token}` }, ip: '127.0.0.1', connection: {} };
}
function makeRes() {
return {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
};
}
describe('adminAuth roles-join fallback (#968)', () => {
const OLD_SECRET = process.env.JWT_SECRET;
beforeAll(() => { process.env.JWT_SECRET = SECRET; });
afterAll(() => { process.env.JWT_SECRET = OLD_SECRET; });
beforeEach(() => { mockJoinError = null; });
it('grants the upgrade-window fallback only for a genuinely missing roles table', async () => {
mockJoinError = new Error('SQLITE_ERROR: no such table: roles');
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.admin.roleName).toBe('super_admin');
});
it.each([
['connection reset', new Error('Connection terminated unexpectedly')],
['deadlock', new Error('deadlock detected')],
['pool exhaustion', new Error('Knex: Timeout acquiring a connection')],
['statement timeout', new Error('canceling statement due to statement timeout')],
])('does NOT fabricate super_admin on a transient failure (%s)', async (_label, err) => {
mockJoinError = err;
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
// Fails closed: request rejected, req.admin never populated. The specific
// status is 401 (adminAuth's blanket outer catch) — what matters is that
// the caller is not elevated and does not reach the route.
expect(next).not.toHaveBeenCalled();
expect(req.admin).toBeUndefined();
expect(res.statusCode).toBe(401);
});
it('does NOT fabricate super_admin when an unrelated table is missing', async () => {
mockJoinError = new Error('SQLITE_ERROR: no such table: admin_sessions');
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(req.admin).toBeUndefined();
});
});
@@ -0,0 +1,72 @@
/**
* The roles-join fallback in apiTokenAuth grants `super_admin` (upgrade-path
* parity with adminAuth). It must therefore fire ONLY when the roles schema is
* genuinely absent — a catch-all turns any transient database failure into a
* privilege escalation that reopens GHSA-9697 for a demoted token owner.
*/
const { isMissingRolesSchema } = require('../../src/middleware/apiTokenAuth');
describe('apiTokenAuth roles-schema fallback predicate (GHSA-9697)', () => {
it('accepts a genuinely missing roles table on both engines', () => {
expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: roles'))).toBe(true);
expect(isMissingRolesSchema(
Object.assign(new Error('relation "roles" does not exist'), { code: '42P01' }),
)).toBe(true);
expect(isMissingRolesSchema(
Object.assign(new Error('column roles.name does not exist'), { code: '42703' }),
)).toBe(true);
});
it('rejects transient failures that must not elevate the caller', () => {
expect(isMissingRolesSchema(new Error('Connection terminated unexpectedly'))).toBe(false);
expect(isMissingRolesSchema(new Error('deadlock detected'))).toBe(false);
expect(isMissingRolesSchema(new Error('Knex: Timeout acquiring a connection'))).toBe(false);
expect(isMissingRolesSchema(new Error('canceling statement due to statement timeout'))).toBe(false);
expect(isMissingRolesSchema(undefined)).toBe(false);
});
it('rejects a missing-table error for an unrelated table', () => {
expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: api_tokens'))).toBe(false);
});
// knex prefixes the failing SQL to err.message, and that SQL always names
// `roles` on this join — so the message substring proves nothing about the
// error, and only an exact driver phrase (or a SQLSTATE) may be trusted.
// These are real knex message shapes, captured from the actual query.
describe('with knex\'s SQL prefix on the message (#968)', () => {
const withSql = (driverMessage) => new Error(
'select `roles`.`name` as `role_name` from `admin_users` '
+ 'left join `roles` on `roles`.`id` = `admin_users`.`role_id` '
+ `where \`admin_users\`.\`id\` = 1 limit 1 - ${driverMessage}`,
);
it('accepts both legitimate upgrade-window states', () => {
// pre-054: the roles table does not exist yet
expect(isMissingRolesSchema(
Object.assign(withSql('SQLITE_ERROR: no such table: roles'), { code: 'SQLITE_ERROR' }),
)).toBe(true);
// post-054, pre-057: roles exists, admin_users.role_id not added yet
expect(isMissingRolesSchema(
Object.assign(withSql('SQLITE_ERROR: no such column: admin_users.role_id'), { code: 'SQLITE_ERROR' }),
)).toBe(true);
});
it('rejects an unrelated "does not exist" fault despite the SQL naming roles', () => {
// pgbouncer transaction pooling loses a named prepared statement
// (SQLSTATE 26000). Transient — the fallback query would succeed on a
// fresh connection, so accepting this would fabricate super_admin.
expect(isMissingRolesSchema(
Object.assign(withSql('prepared statement "S_1" does not exist'), { code: '26000' }),
)).toBe(false);
// The DB role/user, not the roles table.
expect(isMissingRolesSchema(
Object.assign(withSql('role "picpeak" does not exist'), { code: '28000' }),
)).toBe(false);
expect(isMissingRolesSchema(
Object.assign(withSql('database "picpeak" does not exist'), { code: '3D000' }),
)).toBe(false);
expect(isMissingRolesSchema(withSql('Connection terminated unexpectedly'))).toBe(false);
});
});
});
@@ -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,35 @@
/**
* Migration 167 (projects.created_by) — idempotent on re-run, reversible,
* and backfills the owner from a project's single linked event (GHSA-wrg5).
*/
const path=require('path'), fs=require('fs'), os=require('os');
process.env.NODE_ENV='test';
process.env.TEST_DATABASE_PATH=path.join(fs.mkdtempSync(path.join(os.tmpdir(),'picpeak-mig167-')),'db.sqlite');
process.env.JWT_SECRET='mig';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const mig = require('../../migrations/core/167_add_projects_created_by');
describe('migration 167', () => {
let db, cleanup;
beforeAll(async()=>{ ({db,cleanup}=await bootCrmDb()); await seedMinimal(db); },120000);
afterAll(async()=>{ if(cleanup) await cleanup(); });
it('is idempotent on re-run and reversible', async () => {
await mig.up(db); // already applied by boot; must no-op
await mig.up(db); // and again
expect(await db.schema.hasColumn('projects','created_by')).toBe(true);
await mig.down(db);
expect(await db.schema.hasColumn('projects','created_by')).toBe(false);
await mig.up(db); // re-apply cleanly
expect(await db.schema.hasColumn('projects','created_by')).toBe(true);
});
it('backfills created_by from a single linked event owner', async () => {
const p = await db('projects').insert({name:'bf',status:'active',created_at:new Date(),updated_at:new Date()}).returning('id');
const pid = p[0]?.id ?? p[0];
await db('events').insert({slug:'bf-ev',event_type:'wedding',event_name:'bf',event_date:'2026-08-01',
host_email:'h@e.com',admin_email:'a@e.com',password_hash:'x',share_token:'t1',share_link:'/g/bf-ev/t1',
created_by: 4242, project_id: pid, expires_at:new Date(Date.now()+864e5).toISOString(),
is_active:1,is_archived:0,is_draft:0,created_at:new Date().toISOString()});
await mig.up(db);
const row = await db('projects').where({id:pid}).first();
expect(row.created_by).toBe(4242);
});
});
@@ -0,0 +1,83 @@
/**
* GHSA-jhcf round 3: scoping the activity feed does nothing about the rows
* already on disk. expenseService used to pass adminId into logActivity's
* `eventId` slot, so upgraded instances carry accounting rows whose event_id
* is an ADMIN id — and the scope predicate happily matches those against a
* same-numbered event the caller owns.
*/
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-mig168-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mig168-test-secret';
const { bootCrmDb } = require('../integration/helpers/crmDb');
const migration = require('../../migrations/core/168_fix_expense_activity_event_id');
describe('migration 168 — legacy accounting activity rows (GHSA-jhcf)', () => {
let db; let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('re-attributes the admin id and clears event_id, leaving real rows alone', async () => {
await db('activity_logs').insert([
// Legacy shape: event_id is really admin #7, no actor recorded.
{
activity_type: 'expense_created',
actor_type: 'system',
actor_id: null,
event_id: 7,
metadata: JSON.stringify({ expenseId: 1 }),
created_at: new Date().toISOString(),
},
{
activity_type: 'incoming_invoice_captured',
actor_type: 'system',
actor_id: null,
event_id: 9,
metadata: JSON.stringify({ inboundDocumentId: 2 }),
created_at: new Date().toISOString(),
},
// A genuine event-scoped row from another subsystem must survive intact.
{
activity_type: 'photo_uploaded',
actor_type: 'admin',
actor_id: 3,
event_id: 7,
metadata: JSON.stringify({}),
created_at: new Date().toISOString(),
},
]);
await migration.up(db);
const expense = await db('activity_logs').where({ activity_type: 'expense_created' }).first();
expect(expense.event_id == null).toBe(true);
expect(Number(expense.actor_id)).toBe(7);
expect(expense.actor_type).toBe('admin');
const captured = await db('activity_logs').where({ activity_type: 'incoming_invoice_captured' }).first();
expect(captured.event_id == null).toBe(true);
expect(Number(captured.actor_id)).toBe(9);
const photo = await db('activity_logs').where({ activity_type: 'photo_uploaded' }).first();
expect(Number(photo.event_id)).toBe(7);
expect(Number(photo.actor_id)).toBe(3);
});
it('is idempotent on re-run', async () => {
await expect(migration.up(db)).resolves.toBeUndefined();
const expense = await db('activity_logs').where({ activity_type: 'expense_created' }).first();
expect(Number(expense.actor_id)).toBe(7);
expect(expense.event_id == null).toBe(true);
});
});
@@ -0,0 +1,242 @@
/**
* Repairing the bundled templates' fixed image height (#1131).
*
* The risk in a migration that rewrites user-visible CSS is doing too much,
* so most of what is pinned here is what it must NOT touch: the other pixel
* heights inside the very same templates (a 1px divider, an 8px scrollbar),
* and any rule a user wrote themselves.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const migration = require('../../migrations/core/175_fix_css_template_photo_height');
const ELEGANT_DARK = `
.photo-card {
border-radius: 12px;
}
.photo-card img {
width: 100%;
height: 200px;
object-fit: cover;
transition: transform 0.3s ease;
}
`;
const LIQUID_GLASS_DARK = `
.gallery-page::after {
content: '';
height: 1px;
background: linear-gradient(90deg, transparent, #fff, transparent);
}
.photo-card img {
width: 100%;
height: 240px;
object-fit: cover;
filter: brightness(0.9);
}
.gallery-page ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
@media (max-width: 640px) {
.photo-card img {
height: 180px;
}
}
`;
describe('migration 175 — CSS template image height (#1131)', () => {
let knex; let tmpDir;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig175-'));
knex = require('knex')({
client: 'sqlite3',
connection: { filename: path.join(tmpDir, 'db.sqlite') },
useNullAsDefault: true,
});
await knex.schema.createTable('css_templates', (t) => {
t.increments('id').primary();
t.string('name');
t.text('css_content');
});
});
afterAll(async () => {
if (knex) await knex.destroy();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => { await knex('css_templates').del(); });
const contentOf = async (name) =>
(await knex('css_templates').where({ name }).first()).css_content;
it('relaxes the default template so the layouts h-full can win', async () => {
await knex('css_templates').insert({ name: 'Elegant Dark', css_content: ELEGANT_DARK });
await migration.up(knex);
const css = await contentOf('Elegant Dark');
expect(css).toContain('height: 100%');
expect(css).not.toContain('height: 200px');
// Everything else about the rule survives.
expect(css).toContain('object-fit: cover');
expect(css).toContain('transition: transform 0.3s ease');
});
it('fixes both the base rule and the mobile override of the dark glass template', async () => {
await knex('css_templates').insert({ name: 'Liquid Glass Dark', css_content: LIQUID_GLASS_DARK });
await migration.up(knex);
const css = await contentOf('Liquid Glass Dark');
expect(css).not.toContain('height: 240px');
expect(css).not.toContain('height: 180px');
expect(css.match(/height: 100%/g)).toHaveLength(2);
});
it('leaves the divider and the scrollbar alone', async () => {
await knex('css_templates').insert({ name: 'Liquid Glass Dark', css_content: LIQUID_GLASS_DARK });
await migration.up(knex);
// The whole reason this matches full rule bodies rather than every
// `height: <n>px`: these are in the same stylesheet and are correct.
const css = await contentOf('Liquid Glass Dark');
expect(css).toContain('height: 1px');
expect(css).toContain('width: 8px');
expect(css).toContain('height: 8px');
});
/**
* The case that forced the scope wider. `sanitizeCSS` strips control
* characters, so any template ever saved through the editor — including a
* save that only changed its name — has had every newline REMOVED. An
* exact-text migration finds nothing on those installs, is recorded as
* applied, and leaves them broken permanently.
*/
it('fixes a template that has been through the editor, newlines and all', async () => {
const { sanitizeCSS } = require('../../src/utils/cssSanitizer');
const { sanitized } = sanitizeCSS(ELEGANT_DARK);
// Precondition: the sanitizer really did flatten it.
expect(sanitized).not.toContain('\n');
expect(sanitized).toContain('height: 200px');
await knex('css_templates').insert({ name: 'Saved Once', css_content: sanitized });
await migration.up(knex);
const css = await contentOf('Saved Once');
expect(css).not.toContain('200px');
expect(css).toContain('height: 100%');
});
it('relaxes a user-authored fixed height too, but only on .photo-card img', async () => {
// Deliberately broader than the seeded text — see the migration header. A
// pixel height on the image cannot be right under any of the seven
// layouts, whoever wrote it; a height anywhere else is none of our
// business.
const mine = '.photo-card img {\n height: 220px;\n}\n.hero { height: 400px; }';
await knex('css_templates').insert({ name: 'My Own', css_content: mine });
await migration.up(knex);
const css = await contentOf('My Own');
expect(css).toContain('height: 100%');
expect(css).not.toContain('220px');
expect(css).toContain('.hero { height: 400px; }');
});
it('does not rewrite other properties that merely end in -height', async () => {
// `line-height: 200px` contains `height: 200px` as a substring, so an
// unanchored pattern silently rewrites it — in a migration that cannot be
// undone.
const mine = [
'.photo-card img {',
' line-height: 200px;',
' max-height: 300px;',
' min-height: 14px;',
' --tile-height: 220px;',
' height: 200px;',
'}',
].join('\n');
await knex('css_templates').insert({ name: 'Adjacent Props', css_content: mine });
await migration.up(knex);
const css = await contentOf('Adjacent Props');
expect(css).toContain('line-height: 200px');
expect(css).toContain('max-height: 300px');
expect(css).toContain('min-height: 14px');
expect(css).toContain('--tile-height: 220px');
// Only the real one moved.
expect(css).toContain('height: 100%');
expect(css).not.toMatch(/(?<![\w-])height:\s*200px/);
});
it('handles a grouped selector list', async () => {
// Requiring `{` straight after `img` skipped these entirely — and the
// migration is still recorded as applied, so the template kept the bug.
const mine = '.photo-card img, .thumbnail img {\n height: 200px;\n}';
await knex('css_templates').insert({ name: 'Grouped', css_content: mine });
await migration.up(knex);
const css = await contentOf('Grouped');
expect(css).toContain('.photo-card img, .thumbnail img {');
expect(css).toContain('height: 100%');
expect(css).not.toContain('200px');
});
it('skips a nested rule rather than rewriting the wrong declaration', async () => {
// Valid nested CSS that passes the validator. A brace-greedy body would
// capture the inner block and rewrite the CAPTION's height, which cannot
// be undone. Leaving it untouched is the lesser evil.
const mine = '.photo-card img {\n & + .caption { height: 200px; }\n}';
await knex('css_templates').insert({ name: 'Nested', css_content: mine });
await migration.up(knex);
expect(await contentOf('Nested')).toBe(mine);
});
it('leaves non-pixel heights on the image alone', async () => {
const mine = '.photo-card img { height: 50vh; }\n.photo-card img { height: auto; }';
await knex('css_templates').insert({ name: 'Relative', css_content: mine });
await migration.up(knex);
expect(await contentOf('Relative')).toBe(mine);
});
it('is idempotent and safe on a row with no CSS', async () => {
await knex('css_templates').insert([
{ name: 'Elegant Dark', css_content: ELEGANT_DARK },
{ name: 'Empty', css_content: null },
]);
await migration.up(knex);
const once = await contentOf('Elegant Dark');
await migration.up(knex);
expect(await contentOf('Elegant Dark')).toBe(once);
expect(await contentOf('Empty')).toBeNull();
});
it('no-ops when the table does not exist yet', async () => {
await knex.schema.dropTable('css_templates');
await expect(migration.up(knex)).resolves.toBeUndefined();
await knex.schema.createTable('css_templates', (t) => {
t.increments('id').primary();
t.string('name');
t.text('css_content');
});
});
});
@@ -0,0 +1,546 @@
/**
* One row per external file per event (#1162).
*
* The migration has two halves and they fail differently: the cleanup can take
* out the wrong row of a pair (losing a thumbnail, orphaning an event's hero),
* and the index can fail to be created at all — leaving an install that looks
* migrated and is still racing. Both are pinned here.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const migration = require('../../migrations/core/176_external_relpath_unique');
describe('migration 176 — unique (event_id, external_relpath) (#1162)', () => {
let knex; let tmpDir;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig186-'));
knex = require('knex')({
client: 'sqlite3',
connection: { filename: path.join(tmpDir, 'db.sqlite') },
useNullAsDefault: true,
});
});
afterAll(async () => {
if (knex) await knex.destroy();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
for (const table of [
'photos', 'events', 'photo_categories', 'photo_feedback',
'photo_admin_marks', 'photo_faces', 'image_access_logs', 'transfer_files',
]) {
await knex.schema.dropTableIfExists(table);
}
await knex.schema.createTable('events', (t) => {
t.increments('id').primary();
t.integer('hero_photo_id');
t.string('download_zip_path');
t.string('download_zip_generated_at');
});
await knex.schema.createTable('photo_categories', (t) => {
t.increments('id').primary();
t.integer('hero_photo_id');
});
await knex.schema.createTable('photos', (t) => {
t.increments('id').primary();
t.integer('event_id');
t.string('external_relpath');
t.string('thumbnail_path');
t.string('source_origin').defaultTo('managed');
t.integer('feedback_count').defaultTo(0);
t.integer('like_count').defaultTo(0);
t.decimal('average_rating', 3, 2).defaultTo(0);
t.integer('favorite_count').defaultTo(0);
t.integer('reaction_count').defaultTo(0);
t.integer('color_label_count').defaultTo(0);
t.string('face_status');
t.integer('view_count').defaultTo(0);
t.integer('download_count').defaultTo(0);
t.integer('face_count');
t.string('face_started_at');
t.text('face_error');
});
// Declared exactly as the real schema declares them — CASCADE and all.
// The point of these tables here is that SQLite does NOT enforce any of
// it (PicPeak never sets `PRAGMA foreign_keys = ON`), so a bare delete of
// the photo row leaves every one of them dangling.
await knex.schema.createTable('photo_feedback', (t) => {
t.increments('id').primary();
t.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
t.integer('event_id');
t.string('feedback_type');
t.text('comment_text');
t.string('guest_identifier');
// Per-person guest identity (migration 078). Nullable: galleries without
// guest identity leave it NULL and fall back to guest_identifier.
t.integer('guest_id');
t.integer('rating');
t.boolean('is_hidden').defaultTo(false);
t.boolean('is_approved').defaultTo(true);
});
await knex.schema.createTable('photo_admin_marks', (t) => {
t.increments('id').primary();
t.integer('photo_id').notNullable().references('id').inTable('photos').onDelete('CASCADE');
t.integer('event_id');
t.integer('admin_id');
t.integer('rating');
// Independently writable alongside rating, per photoAdminMarksService.
t.string('color_label', 16);
t.unique(['photo_id', 'admin_id'], 'photo_admin_marks_photo_admin_uniq');
});
await knex.schema.createTable('photo_faces', (t) => {
t.increments('id').primary();
t.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE');
t.integer('event_id');
// purgePhotoFaces rebuilds the people that lose members, so the cluster
// link and the vectors recomputeCentroid reads have to be here for this
// to exercise the real path rather than a stub.
t.integer('person_id');
t.binary('embedding');
t.float('det_score');
});
await knex.schema.createTable('image_access_logs', (t) => {
t.increments('id').primary();
t.integer('photo_id');
});
await knex.schema.createTable('transfer_files', (t) => {
t.increments('id').primary();
t.integer('transfer_id');
t.integer('photo_id');
t.unique(['transfer_id', 'photo_id'], 'transfer_files_unique');
});
});
/** Two duplicate rows for the same file: id 1 survives, id 2 is doomed. */
const seedPair = async () => {
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
]);
};
const rows = () => knex('photos').orderBy('id', 'asc').select('*');
it('collapses a duplicated pair to one row and leaves distinct paths alone', async () => {
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't1', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't2', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/y.jpg', thumbnail_path: 't3', source_origin: 'external' },
]);
await migration.up(knex);
const after = await rows();
expect(after.map((r) => r.external_relpath)).toEqual(['a/x.jpg', 'a/y.jpg']);
// Lowest id survives when both sides are equally complete.
expect(after[0].id).toBe(1);
});
it('does not collapse the same path across different events', async () => {
// The constraint is per event. Two events referencing the same NAS folder
// is a supported setup, and treating those as duplicates would delete one
// event's entire library.
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
{ event_id: 2, external_relpath: 'a/x.jpg', source_origin: 'external' },
]);
await migration.up(knex);
expect(await knex('photos').count('* as c').first()).toEqual({ c: 2 });
});
it('never touches managed rows, however many carry NULL', async () => {
// Every managed photo has external_relpath NULL. Grouping on it without
// the NOT NULL filter would make them all one enormous "duplicate" group
// and delete the entire library bar one row.
await knex('photos').insert([
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
]);
await migration.up(knex);
expect(await knex('photos').count('* as c').first()).toEqual({ c: 3 });
});
it('keeps the row that has a thumbnail, not merely the lowest id', async () => {
// An import killed mid-flight leaves rows without a thumbnail. Dropping
// the completed one would blank a tile in the grid for no reason.
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: null, source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 'thumb.jpg', source_origin: 'external' },
]);
await migration.up(knex);
const after = await rows();
expect(after).toHaveLength(1);
expect(after[0].thumbnail_path).toBe('thumb.jpg');
});
it('repoints a hero that pointed at the row being removed', async () => {
// events.hero_photo_id is ON DELETE SET NULL, so without this the cleanup
// silently strips the event's hero image — a visible regression caused
// entirely by the fix.
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
]);
await knex('events').insert({ id: 1, hero_photo_id: 2 });
await knex('photo_categories').insert({ id: 1, hero_photo_id: 2 });
await migration.up(knex);
expect((await knex('events').where({ id: 1 }).first()).hero_photo_id).toBe(1);
expect((await knex('photo_categories').where({ id: 1 }).first()).hero_photo_id).toBe(1);
});
it('leaves a hero that pointed at the survivor untouched', async () => {
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', thumbnail_path: 't', source_origin: 'external' },
]);
await knex('events').insert({ id: 1, hero_photo_id: 1 });
await migration.up(knex);
expect((await knex('events').where({ id: 1 }).first()).hero_photo_id).toBe(1);
});
it('makes a second insert of the same path impossible afterwards', async () => {
// The whole point. Without this the route is still racing, and the
// migration is recorded as applied.
await knex('photos').insert({ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' });
await migration.up(knex);
await expect(
knex('photos').insert({ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' })
).rejects.toThrow(/unique/i);
});
it('still admits managed rows once the index exists', async () => {
await migration.up(knex);
await knex('photos').insert([
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
]);
expect(await knex('photos').count('* as c').first()).toEqual({ c: 2 });
});
it('leaves nothing dangling behind the deleted row', async () => {
// SQLite never enforces the ON DELETE CASCADE these tables declare, so a
// bare delete strands biometric embeddings, feedback and marks pointing at
// a photo id that no longer exists — on every SQLite install.
await seedPair();
await knex('photo_faces').insert({ photo_id: 2, event_id: 1 });
await knex('image_access_logs').insert({ photo_id: 2 });
await migration.up(knex);
expect(await knex('photo_faces').where('photo_id', 2).first()).toBeUndefined();
expect(await knex('image_access_logs').where('photo_id', 2).first()).toBeUndefined();
});
it('does not carry the duplicate\'s faces over to the survivor', async () => {
// Both rows were scanned independently, so the survivor already holds its
// own embeddings. Moving these would fabricate a second copy of every face
// and split the person clusters built from them.
await seedPair();
await knex('photo_faces').insert([{ photo_id: 1, event_id: 1 }, { photo_id: 2, event_id: 1 }]);
await migration.up(knex);
expect(await knex('photo_faces').count('* as c').first()).toEqual({ c: 1 });
});
it('moves a guest comment to the survivor rather than deleting it', async () => {
// The duplicates were separate tiles in the grid, so a guest could have
// commented on either. Silently dropping that inside a fix for silent data
// loss would be its own bug.
await seedPair();
await knex('photo_feedback').insert({
photo_id: 2, event_id: 1, feedback_type: 'comment',
comment_text: 'lovely shot', guest_identifier: 'guest-a',
});
await migration.up(knex);
const rows = await knex('photo_feedback');
expect(rows).toHaveLength(1);
expect(rows[0].photo_id).toBe(1);
expect(rows[0].comment_text).toBe('lovely shot');
});
it('keeps both comments when the same guest commented on both tiles', async () => {
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 1, event_id: 1, feedback_type: 'comment', comment_text: 'one', guest_identifier: 'g' },
{ photo_id: 2, event_id: 1, feedback_type: 'comment', comment_text: 'two', guest_identifier: 'g' },
]);
await migration.up(knex);
const rows = await knex('photo_feedback').orderBy('id');
expect(rows.map((r) => r.comment_text)).toEqual(['one', 'two']);
expect(rows.every((r) => r.photo_id === 1)).toBe(true);
});
it('does not double-count a like the same guest left on both tiles', async () => {
// Unlike comments, a like is a per-guest toggle: moving it would show two
// likes from one person.
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 1, event_id: 1, feedback_type: 'like', guest_identifier: 'g' },
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'g' },
]);
await migration.up(knex);
expect(await knex('photo_feedback').count('* as c').first()).toEqual({ c: 1 });
});
it('moves a like from a guest the survivor has never seen', async () => {
await seedPair();
await knex('photo_feedback').insert({
photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'other',
});
await migration.up(knex);
const rows = await knex('photo_feedback');
expect(rows).toHaveLength(1);
expect(rows[0].photo_id).toBe(1);
});
it('moves an admin mark, and drops it when that admin already marked the survivor', async () => {
// photo_admin_marks is UNIQUE(photo_id, admin_id), so a blind move would
// throw and abort the migration.
await seedPair();
await knex('photo_admin_marks').insert([
{ photo_id: 1, event_id: 1, admin_id: 7, rating: 5 },
{ photo_id: 2, event_id: 1, admin_id: 7, rating: 2 },
{ photo_id: 2, event_id: 1, admin_id: 9, rating: 4 },
]);
await migration.up(knex);
const rows = await knex('photo_admin_marks').orderBy('admin_id');
expect(rows.map((r) => [r.admin_id, r.rating])).toEqual([[7, 5], [9, 4]]);
expect(rows.every((r) => r.photo_id === 1)).toBe(true);
});
it('respects the transfer_files uniqueness when moving membership', async () => {
await seedPair();
await knex('transfer_files').insert([
{ transfer_id: 3, photo_id: 1 },
{ transfer_id: 3, photo_id: 2 },
{ transfer_id: 4, photo_id: 2 },
]);
await migration.up(knex);
const rows = await knex('transfer_files').orderBy('transfer_id');
expect(rows.map((r) => r.transfer_id)).toEqual([3, 4]);
expect(rows.every((r) => r.photo_id === 1)).toBe(true);
});
it('recomputes the survivor\'s feedback totals after reparenting rows', async () => {
// photos carries denormalized counters (migration 033). A survivor that
// now OWNS the feedback but still renders zero is the visible half of
// getting this wrong.
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'g1' },
{ photo_id: 2, event_id: 1, feedback_type: 'rating', rating: 4, guest_identifier: 'g1' },
]);
await migration.up(knex);
const survivor = await knex('photos').where('id', 1).first();
expect(survivor.like_count).toBe(1);
expect(Number(survivor.average_rating)).toBe(4);
expect(survivor.feedback_count).toBe(1);
});
it('keeps two people who share a device apart', async () => {
// guest_identifier is per-device; guest_id is per-person (migration 078),
// and feedbackService scopes by guest_id when it is present. Keying on the
// identifier alone would read these as one person and delete a rating.
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 1, event_id: 1, feedback_type: 'rating', rating: 5, guest_identifier: 'shared', guest_id: 10 },
{ photo_id: 2, event_id: 1, feedback_type: 'rating', rating: 2, guest_identifier: 'shared', guest_id: 11 },
]);
await migration.up(knex);
const rows = await knex('photo_feedback').orderBy('guest_id');
expect(rows.map((r) => [r.guest_id, r.rating])).toEqual([[10, 5], [11, 2]]);
});
it('still dedupes one person voting on both tiles', async () => {
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 1, event_id: 1, feedback_type: 'like', guest_identifier: 'shared', guest_id: 10 },
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'shared', guest_id: 10 },
]);
await migration.up(knex);
expect(await knex('photo_feedback').count('* as c').first()).toEqual({ c: 1 });
});
it('still clears face rows on a branch that has no face feature', async () => {
// DIVERGES FROM MAIN, deliberately. Face recognition (#1090) is main-only:
// there is no faceProcessor on this branch, so purgePhotoFaces cannot be
// called and there are no event_people counts or centroids to reconcile.
// What still matters is the half that is not optional — the rows must not
// dangle, because SQLite never enforces the CASCADE that would remove
// them. The service reaches for purgePhotoFaces, finds nothing, and falls
// back to a plain delete; this pins that fallback.
//
// If faces are ever backported, main's version of this test comes with
// them.
await seedPair();
await knex('photo_faces').insert({ photo_id: 2, event_id: 1, person_id: 5 });
await migration.up(knex);
expect(await knex('photo_faces').count('* as c').first()).toEqual({ c: 0 });
});
it('keeps a hidden moderation record from swallowing the visible replacement', async () => {
// feedbackService lets both coexist and counts only the visible one.
await seedPair();
await knex('photo_feedback').insert([
{ photo_id: 1, event_id: 1, feedback_type: 'like', guest_identifier: 'g', is_hidden: true },
{ photo_id: 2, event_id: 1, feedback_type: 'like', guest_identifier: 'g', is_hidden: false },
]);
await migration.up(knex);
expect(await knex('photo_feedback').count('* as c').first()).toEqual({ c: 2 });
});
it('merges the independent halves of one admin\'s mark', async () => {
// rating and color_label are written independently, so the same admin can
// have rated one tile and coloured the other.
await seedPair();
await knex('photo_admin_marks').insert([
{ photo_id: 1, event_id: 1, admin_id: 7, rating: 5, color_label: null },
{ photo_id: 2, event_id: 1, admin_id: 7, rating: null, color_label: 'red' },
]);
await migration.up(knex);
const rows = await knex('photo_admin_marks');
expect(rows).toHaveLength(1);
expect([rows[0].rating, rows[0].color_label]).toEqual([5, 'red']);
});
it('requeues the survivor when the duplicate held the only scan', async () => {
// Otherwise the sole embeddings go with the purge and nothing re-queues:
// the photo just silently stops having a face.
await seedPair();
await knex('photo_faces').insert({ photo_id: 2, event_id: 1 });
await migration.up(knex);
expect((await knex('photos').where('id', 1).first()).face_status).toBe('pending');
});
it('carries the duplicate\'s views and downloads over', async () => {
await seedPair();
await knex('photos').where('id', 1).update({ view_count: 2, download_count: 1 });
await knex('photos').where('id', 2).update({ view_count: 5, download_count: 3 });
await migration.up(knex);
const survivor = await knex('photos').where('id', 1).first();
expect([survivor.view_count, survivor.download_count]).toEqual([7, 4]);
});
it('fails loudly rather than recording itself applied without the index', async () => {
// Swallowing a failed CREATE INDEX would leave the install permanently
// racy — the in-flight guard only covers one process — with nothing to
// trigger a retry. Driven through the helper the migration calls, against
// a table that still holds duplicates — i.e. what it would face if the
// dedupe above had not achieved uniqueness.
await seedPair();
const { createExternalRelpathIndex } = require('../../src/services/externalPhotoDedupe');
await expect(createExternalRelpathIndex(knex)).rejects.toThrow(/unique/i);
});
it('invalidates the pre-built download zip for the affected event', async () => {
// The cached archive still contains the rows just removed, and every
// ordinary photo-deletion path invalidates it for exactly that reason.
// getZipInfo treats a cleared record as a miss and rebuilds on request.
await seedPair();
await knex('events').insert({
id: 1, download_zip_path: 'events/active/x/.download-cache/all.zip',
download_zip_generated_at: '2026-01-01',
});
await migration.up(knex);
const ev = await knex('events').where('id', 1).first();
expect(ev.download_zip_path).toBeNull();
expect(ev.download_zip_generated_at).toBeNull();
});
it('leaves an untouched event\'s zip alone', async () => {
await seedPair();
await knex('events').insert([
{ id: 1, download_zip_path: 'a.zip', download_zip_generated_at: '2026-01-01' },
{ id: 2, download_zip_path: 'b.zip', download_zip_generated_at: '2026-01-01' },
]);
await migration.up(knex);
expect((await knex('events').where('id', 2).first()).download_zip_path).toBe('b.zip');
});
it('is idempotent', async () => {
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
]);
await migration.up(knex);
const once = await rows();
await migration.up(knex);
expect(await rows()).toEqual(once);
});
it('rolls back to an unconstrained table', async () => {
await migration.up(knex);
await migration.down(knex);
await knex('photos').insert([
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
{ event_id: 1, external_relpath: 'a/x.jpg', source_origin: 'external' },
]);
expect(await knex('photos').count('* as c').first()).toEqual({ c: 2 });
});
it('no-ops before 041 has added the column', async () => {
await knex.schema.dropTableIfExists('photos');
await knex.schema.createTable('photos', (t) => { t.increments('id').primary(); });
await expect(migration.up(knex)).resolves.toBeUndefined();
});
});
@@ -0,0 +1,364 @@
/**
* Folding the event's base path into every external row (#1163).
*
* Two things can go wrong and both are silent, which is why they are pinned
* here rather than left to review: folding a path that was ALREADY folded
* (every original moves), and "repairing" a healthy install because the media
* root happened to be unmounted when the migration ran (every original moves).
*
* The repair itself is driven against a real temp directory tree, because the
* whole mechanism is "is this file actually there" and a mocked fs would only
* be testing the mock.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
describe('migration 177 — external_relpath from the media root (#1163)', () => {
let knex; let tmpDir; let mediaRoot; let migration;
/** Writes `bytes` bytes and returns the size, so fixtures can record it the
* way an import would have. */
const touch = async (rel, bytes = 8) => {
const full = path.join(mediaRoot, rel);
await fs.promises.mkdir(path.dirname(full), { recursive: true });
await fs.promises.writeFile(full, Buffer.alloc(bytes));
return bytes;
};
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig187-'));
mediaRoot = path.join(tmpDir, 'media');
await fs.promises.mkdir(mediaRoot, { recursive: true });
process.env.EXTERNAL_MEDIA_ROOT = mediaRoot;
// The service caches the root on first call, so it must not have been
// resolved before EXTERNAL_MEDIA_ROOT was set above.
jest.resetModules();
migration = require('../../migrations/core/177_external_relpath_from_root');
knex = require('knex')({
client: 'sqlite3',
connection: { filename: path.join(tmpDir, 'db.sqlite') },
useNullAsDefault: true,
});
});
afterAll(async () => {
if (knex) await knex.destroy();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
delete process.env.EXTERNAL_MEDIA_ROOT;
});
beforeEach(async () => {
await knex.schema.dropTableIfExists('photos');
await knex.schema.dropTableIfExists('events');
await knex.schema.dropTableIfExists('app_settings');
await knex.schema.createTable('events', (t) => {
t.increments('id').primary();
t.string('external_path');
});
await knex.schema.createTable('photos', (t) => {
t.increments('id').primary();
t.integer('event_id');
t.string('external_relpath');
t.integer('size_bytes');
t.string('source_origin').defaultTo('managed');
});
await knex.schema.createTable('app_settings', (t) => {
t.increments('id').primary();
t.string('setting_key');
t.text('setting_value');
t.string('setting_type');
t.string('updated_at');
});
await fs.promises.rm(mediaRoot, { recursive: true, force: true });
await fs.promises.mkdir(mediaRoot, { recursive: true });
});
const relpaths = async () =>
(await knex('photos').orderBy('id', 'asc').select('external_relpath'))
.map((r) => r.external_relpath);
it('folds the base path into every row of a healthy event', async () => {
await touch('Trip/Leknes/a.jpg');
await touch('Trip/Leknes/b.jpg');
await knex('events').insert({ id: 1, external_path: 'Trip' });
await knex('photos').insert([
{ event_id: 1, external_relpath: 'Leknes/a.jpg', source_origin: 'external' },
{ event_id: 1, external_relpath: 'Leknes/b.jpg', source_origin: 'external' },
]);
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/Leknes/a.jpg', 'Trip/Leknes/b.jpg']);
});
it('repairs rows an earlier import had rebased', async () => {
// The reported shape: a parent imported first, a child imported second, so
// events.external_path is the child and the parent's rows resolve into a
// path that does not exist.
const oldSize = await touch('Trip/Leknes/old.jpg', 11); // from the first import
const newSize = await touch('Trip/Sub/new.jpg', 22); // from the second
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
await knex('photos').insert([
{ event_id: 1, external_relpath: 'Leknes/old.jpg', size_bytes: oldSize, source_origin: 'external' },
{ event_id: 1, external_relpath: 'new.jpg', size_bytes: newSize, source_origin: 'external' },
]);
await migration.up(knex);
// The old row is placed where the file actually is; the new one keeps
// resolving exactly where it resolved before.
expect(await relpaths()).toEqual(['Trip/Leknes/old.jpg', 'Trip/Sub/new.jpg']);
});
it('refuses an ancestor whose file is a different size', async () => {
// The dangerous case: the row's own file was simply deleted, and an
// UNRELATED file one directory up happens to share its name. Adopting it
// would make downloads serve the wrong original — worse than a dead link.
await touch('Trip/photo.jpg', 999);
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
await knex('photos').insert({
event_id: 1, external_relpath: 'photo.jpg', size_bytes: 42, source_origin: 'external',
});
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/Sub/photo.jpg']);
});
it('refuses an ancestor when the row records no size to check against', async () => {
// Nothing to verify provenance with, so the row stays where it resolves
// today rather than adopting a same-named stranger.
await touch('Trip/photo.jpg', 100);
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
await knex('photos').insert({
event_id: 1, external_relpath: 'photo.jpg', size_bytes: null, source_origin: 'external',
});
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/Sub/photo.jpg']);
});
it('leaves nothing folded when a rewrite fails partway', async () => {
// Without a transaction, a crash between the first event's UPDATE and the
// marker leaves mixed formats behind — and the next run folds the already
// folded rows a second time, putting every original one directory deeper.
await touch('A/one.jpg');
await touch('B/two.jpg');
await knex('events').insert([
{ id: 1, external_path: 'A' },
{ id: 2, external_path: 'B' },
]);
await knex('photos').insert([
{ event_id: 1, external_relpath: 'one.jpg', source_origin: 'external' },
{ event_id: 2, external_relpath: 'two.jpg', source_origin: 'external' },
]);
// app_settings is written last, in the same transaction as the rewrites.
await knex.schema.dropTableIfExists('app_settings_backup');
await knex.raw('CREATE TRIGGER fail_marker BEFORE INSERT ON app_settings '
+ "BEGIN SELECT RAISE(ABORT, 'boom'); END");
await expect(migration.up(knex)).rejects.toThrow(/boom/);
await knex.raw('DROP TRIGGER fail_marker');
// Every row still base-relative, and no marker — so a retry is correct.
expect(await relpaths()).toEqual(['one.jpg', 'two.jpg']);
expect(await knex('app_settings').where('setting_key', 'external_relpath_root_relative').first())
.toBeUndefined();
});
it('removes the losing row when two paths converge, instead of stranding it', async () => {
// Trip/Sub/c.jpg imported once via `Trip` (as `Sub/c.jpg`) and once via
// `Trip/Sub` (as `c.jpg`). Both fold to the same path. Skipping the loser
// would leave it base-relative under a root-only resolver — pointing at
// <root>/c.jpg — with the marker claiming the conversion is complete.
const size = await touch('Trip/Sub/c.jpg', 33);
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
await knex('photos').insert([
{ event_id: 1, external_relpath: 'Sub/c.jpg', size_bytes: size, source_origin: 'external' },
{ event_id: 1, external_relpath: 'c.jpg', size_bytes: size, source_origin: 'external' },
]);
await migration.up(knex);
const rows = await knex('photos').select('external_relpath');
expect(rows).toHaveLength(1);
expect(rows[0].external_relpath).toBe('Trip/Sub/c.jpg');
});
it('survives a final path that equals another row\'s current path', async () => {
// `photo.jpg` repairs to `Trip/photo.jpg` while the row already holding
// `Trip/photo.jpg` folds to `Trip/Sub/Trip/photo.jpg`. Every FINAL value is
// distinct, but a one-pass rewrite collides halfway through — and on
// Postgres that 23505 is misread by the migration runner as "already
// applied", leaving everything unconverted.
const a = await touch('Trip/photo.jpg', 11);
const b = await touch('Trip/Sub/Trip/photo.jpg', 22);
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
await knex('photos').insert([
{ event_id: 1, external_relpath: 'photo.jpg', size_bytes: a, source_origin: 'external' },
{ event_id: 1, external_relpath: 'Trip/photo.jpg', size_bytes: b, source_origin: 'external' },
]);
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/photo.jpg', 'Trip/Sub/Trip/photo.jpg']);
});
it('does not re-prefix a row inserted while the probe was running', async () => {
// Phase 1 runs outside the transaction and can take minutes on a cold
// mount. An import finishing in that window writes an already
// root-relative row, which a `where event_id` bulk update would prefix a
// second time with the stale base.
await touch('Trip/a.jpg');
await knex('events').insert({ id: 1, external_path: 'Trip' });
await knex('photos').insert({ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' });
const { foldExternalRelpaths } = require('../../src/services/externalRelpathFold');
const realStat = fs.promises.stat;
let injected = false;
jest.spyOn(fs.promises, 'access').mockImplementation(async (...args) => {
if (!injected) {
injected = true;
await knex('photos').insert({
event_id: 1, external_relpath: 'Trip/late.jpg', source_origin: 'external',
});
}
return realStat(args[0]).then(() => undefined);
});
await foldExternalRelpaths(knex);
fs.promises.access.mockRestore();
expect((await relpaths()).sort()).toEqual(['Trip/a.jpg', 'Trip/late.jpg']);
});
it('leaves a row it cannot place resolving where it resolves today', async () => {
// Never guess below current behaviour: a file that is genuinely gone must
// not have its path rewritten to some other file that happens to exist.
await touch('Trip/Sub/present.jpg');
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
await knex('photos').insert([
{ event_id: 1, external_relpath: 'present.jpg', source_origin: 'external' },
{ event_id: 1, external_relpath: 'vanished.jpg', source_origin: 'external' },
]);
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/Sub/present.jpg', 'Trip/Sub/vanished.jpg']);
});
it('folds without repairing when the media root is unmounted', async () => {
// An unmounted share leaves the mountpoint as an empty directory, so every
// file looks missing. Repairing off that signal would move every original
// on a perfectly healthy install.
await knex('events').insert({ id: 1, external_path: 'Trip/Sub' });
await knex('photos').insert([
{ event_id: 1, external_relpath: 'Leknes/a.jpg', source_origin: 'external' },
]);
// mediaRoot is empty — see beforeEach.
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/Sub/Leknes/a.jpg']);
});
it('leaves managed rows alone', async () => {
await touch('Trip/a.jpg');
await knex('events').insert({ id: 1, external_path: 'Trip' });
await knex('photos').insert([
{ event_id: 1, external_relpath: null, source_origin: 'managed' },
{ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' },
]);
await migration.up(knex);
expect(await relpaths()).toEqual([null, 'Trip/a.jpg']);
});
it('leaves an event with no base path alone — its rows are already root-relative', async () => {
await touch('a.jpg');
await knex('events').insert({ id: 1, external_path: null });
await knex('photos').insert({ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' });
await migration.up(knex);
expect(await relpaths()).toEqual(['a.jpg']);
});
it('folds each event with its own base', async () => {
await touch('A/one.jpg');
await touch('B/two.jpg');
await knex('events').insert([
{ id: 1, external_path: 'A' },
{ id: 2, external_path: 'B' },
]);
await knex('photos').insert([
{ event_id: 1, external_relpath: 'one.jpg', source_origin: 'external' },
{ event_id: 2, external_relpath: 'two.jpg', source_origin: 'external' },
]);
await migration.up(knex);
expect(await relpaths()).toEqual(['A/one.jpg', 'B/two.jpg']);
});
it('tolerates a base path with stray slashes', async () => {
await touch('Trip/a.jpg');
await knex('events').insert({ id: 1, external_path: '/Trip/' });
await knex('photos').insert({ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' });
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/a.jpg']);
});
it('does not fold twice when run again', async () => {
// The failure this guards is total: every original on the install moves one
// directory deeper, and there is no undo.
await touch('Trip/a.jpg');
await knex('events').insert({ id: 1, external_path: 'Trip' });
await knex('photos').insert({ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' });
await migration.up(knex);
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/a.jpg']);
});
it('does not fold twice when the base repeats in the relpath', async () => {
// The inference this migration deliberately does NOT use: `Trip/x.jpg`
// under base `Trip` already "starts with the base", but has not been
// folded — it is a subfolder that shares its parent's name.
await touch('Trip/Trip/x.jpg');
await knex('events').insert({ id: 1, external_path: 'Trip' });
await knex('photos').insert({ event_id: 1, external_relpath: 'Trip/x.jpg', source_origin: 'external' });
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/Trip/x.jpg']);
});
it('rollback does not clear the marker, so a re-run cannot double-fold', async () => {
await touch('Trip/a.jpg');
await knex('events').insert({ id: 1, external_path: 'Trip' });
await knex('photos').insert({ event_id: 1, external_relpath: 'a.jpg', source_origin: 'external' });
await migration.up(knex);
await migration.down(knex);
await migration.up(knex);
expect(await relpaths()).toEqual(['Trip/a.jpg']);
});
it('no-ops before 041 has added the column', async () => {
await knex.schema.dropTableIfExists('photos');
await knex.schema.createTable('photos', (t) => { t.increments('id').primary(); });
await expect(migration.up(knex)).resolves.toBeUndefined();
});
});
@@ -0,0 +1,83 @@
/**
* Legacy preview keys must not survive the encoder change.
*
* The old generator kept the SOURCE basename verbatim while always writing
* JPEG, so a `.webp` upload produced `preview_shot.webp` holding a JPEG. The
* route now derives Content-Type from the key, and sets `nosniff` — so that
* legacy object would be announced as image/webp and render as a broken image.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const migration = require('../../migrations/core/178_reset_legacy_preview_paths');
describe('migration 178 — legacy preview keys (#1166 follow-up)', () => {
let knex; let tmpDir;
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-mig188-'));
knex = require('knex')({
client: 'sqlite3',
connection: { filename: path.join(tmpDir, 'db.sqlite') },
useNullAsDefault: true,
});
});
afterAll(async () => {
if (knex) await knex.destroy();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
await knex.schema.dropTableIfExists('photos');
await knex.schema.createTable('photos', (t) => {
t.increments('id').primary();
t.string('preview_path');
t.string('thumbnail_path');
});
});
it('clears the mislabelled .webp keys that would render broken', async () => {
await knex('photos').insert({ preview_path: 'previews/preview_shot.webp' });
await migration.up(knex);
expect((await knex('photos').first()).preview_path).toBeNull();
});
it('clears .jpg keys too, because a byte-correct one can still be flattened', async () => {
// A legacy .jpg key is valid JPEG, but it may be a flattened rendition of a
// transparent or animated source, and nothing in the key says so. One lazy
// regeneration is cheaper than reasoning about which of them lied.
await knex('photos').insert([
{ preview_path: 'previews/preview_a.jpg' },
{ preview_path: 'previews/preview_b.png' },
]);
await migration.up(knex);
expect(await knex('photos').whereNotNull('preview_path').count('* as c').first()).toEqual({ c: 0 });
});
it('leaves thumbnails alone — they are a different cache', async () => {
await knex('photos').insert({ preview_path: 'previews/p.jpg', thumbnail_path: 'thumbnails/t.jpg' });
await migration.up(knex);
expect((await knex('photos').first()).thumbnail_path).toBe('thumbnails/t.jpg');
});
it('is idempotent and safe with nothing to clear', async () => {
await migration.up(knex);
await expect(migration.up(knex)).resolves.toBeUndefined();
});
it('no-ops before 104 has added the column', async () => {
await knex.schema.dropTableIfExists('photos');
await knex.schema.createTable('photos', (t) => { t.increments('id').primary(); });
await expect(migration.up(knex)).resolves.toBeUndefined();
});
});
@@ -83,7 +83,7 @@ describe('admin CRM routes — auth + permission gate', () => {
// Invalid: signed with a different secret. adminAuth must reject.
const jwt = require('jsonwebtoken');
invalidToken = jwt.sign({ id: adminId, type: 'admin' }, 'WRONG-SECRET', { issuer: 'picpeak-auth' });
}, 60000);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -180,6 +180,27 @@ describe('admin events CRUD endpoints (smoke)', () => {
});
expect(res.status).toBe(404);
});
// #822 — hero_logo_visible/position are nullable (null = "inherit the global
// branding toggle"), but the validator used .optional() without
// { nullable: true }, so an explicit null was rejected with 400.
it('accepts hero_logo_visible: null and stores NULL (inherit)', async () => {
const id = await insertEvent(db, adminId, { hero_logo_visible: 1 });
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
hero_logo_visible: null,
});
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.hero_logo_visible).toBeNull();
});
it('still rejects a non-boolean hero_logo_visible', async () => {
const id = await insertEvent(db, adminId);
const res = await auth(request(app).put(`/api/admin/events/${id}`)).send({
hero_logo_visible: 'maybe',
});
expect(res.status).toBe(400);
});
});
describe('DELETE /:id', () => {
@@ -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);
});
});
+2 -2
View File
@@ -39,7 +39,7 @@ const {
bootCrmDb, mintAdminToken, buildRouteApp,
} = require('../integration/helpers/crmDb');
jest.setTimeout(60000);
jest.setTimeout(120000);
let db;
let cleanup;
@@ -95,7 +95,7 @@ beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
adminApp = buildRouteApp('/api/admin/auth', require('../../src/routes/adminAuth'));
authApp = buildRouteApp('/api/auth', require('../../src/routes/auth'));
}, 60000);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -0,0 +1,238 @@
/**
* Admin photo view route Content-Type (#908).
*
* The route built `image/<ext>` from the filename, producing invalid
* types like image/mp4 for videos. AdminAuthenticatedVideo fetches this
* URL into a blob whose type inherits the header, and browsers refuse to
* play a <video> blob labeled image/* — blank/grey admin video preview.
*
* Pins (incl. external-review hardening):
* - the header is ALWAYS image/* or video/*: a stored non-media MIME
* (chunked uploads store the client-sent type unvalidated) is never
* echoed — text/html inline under the app origin would be XSS
* - stored video/ MIME wins; MIME-less videos map from the extension
* (.mov → video/quicktime), unknown video extensions get video/mp4
* - images IGNORE the stored MIME (migration 039 backfilled image/jpeg
* onto every legacy row, PNGs included) and use the extension,
* normalized (jpg → image/jpeg); extensionless files get image/jpeg
*/
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-admin-ct-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-ct-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admin-ct-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'admin-ct-test-event';
describe('admin photo view Content-Type (#908)', () => {
let db;
let cleanup;
let app;
let eventId;
let adminToken;
const addPhoto = async (filename, extra = {}) => {
const dir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), Buffer.from(`bytes-${filename}`));
const r = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
...extra,
}).returning('id');
return r[0]?.id ?? r[0];
};
const getPhotoRes = (photoId) => request(app)
.get(`/api/admin/photos/${eventId}/photo/${photoId}`)
.set('Authorization', `Bearer ${adminToken}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Admin CT Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'admin-ct-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'admin-ct-admin',
email: 'admin-ct-admin@example.com',
password_hash: await bcrypt.hash('AdminCt123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'admin-ct-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('serves a video with its stored mime_type, not image/<ext>', async () => {
const id = await addPhoto('clip.mp4', { media_type: 'video', mime_type: 'video/mp4' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
});
it('maps MIME-less videos from their extension (.mov → video/quicktime)', async () => {
const id = await addPhoto('clip-nomime.mov', { media_type: 'video' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/quicktime');
});
it('falls back to video/mp4 for a video with an unknown extension', async () => {
const id = await addPhoto('clip-unknown.xyz', { media_type: 'video' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
});
it('rejects malformed video/ MIME values that would break setHeader', async () => {
// Header-invalid chars in the stored value must not 500 the route —
// fall back to the extension map instead.
const id = await addPhoto('crlf.mp4', {
media_type: 'video',
mime_type: 'video/mp4\r\nX-Evil: 1',
});
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
expect(res.headers['x-evil']).toBeUndefined();
const bare = await addPhoto('bare.webm', { media_type: 'video', mime_type: 'video/' });
const res2 = await getPhotoRes(bare);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('video/webm');
});
it('preserves an auto-imported avif via the safe stored-MIME allowlist', async () => {
// .avif isn't in EXTENSION_TO_MIME; s3AutoImporter stores image/avif.
// Map-only would mislabel it image/jpeg — the allowlist keeps it.
const id = await addPhoto('imported.avif', { mime_type: 'image/avif' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/avif');
});
it('preserves other importer raster types too (apng, x-icon)', async () => {
const apng = await addPhoto('anim.apng', { mime_type: 'image/apng' });
expect((await getPhotoRes(apng)).headers['content-type']).toBe('image/apng');
const ico = await addPhoto('fav.ico', { mime_type: 'image/x-icon' });
expect((await getPhotoRes(ico)).headers['content-type']).toBe('image/x-icon');
});
it('does NOT honor a stored scriptable image type (image/svg+xml)', async () => {
// svg is inline-scriptable and must never be echoed — allowlist excludes it.
const id = await addPhoto('vector.svg', { mime_type: 'image/svg+xml' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
it('never echoes a stored non-media MIME type (inline XSS guard)', async () => {
const id = await addPhoto('evil.png', { mime_type: 'text/html' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('ignores the migration-039 image/jpeg backfill on legacy PNG rows', async () => {
const id = await addPhoto('legacy.png', { mime_type: 'image/jpeg' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('normalizes jpg to the canonical image/jpeg', async () => {
const id = await addPhoto('shot.jpg');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
it('keeps the extension fallback for images without a stored mime_type', async () => {
const id = await addPhoto('shot.png');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('handles Object.prototype key extensions without a 500 (.constructor)', async () => {
// The extension-to-MIME lookup must be own-property only — a raw
// index access returns an inherited function for these keys and the
// downstream startsWith throws. Serve image/jpeg instead of 500.
const id = await addPhoto('payload.constructor');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
const id2 = await addPhoto('payload.__proto__', { media_type: 'video' });
const res2 = await getPhotoRes(id2);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('video/mp4');
});
it('does not synthesize types from unmapped image extensions', async () => {
// Raw interpolation would produce image/svg+xml (scriptable inline)
// or arbitrary strings from client-controlled filenames — the shared
// map is the allowlist, everything else is served as image/jpeg.
const svg = await addPhoto('vector.svg+xml');
const res = await getPhotoRes(svg);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
const weird = await addPhoto('weird.xyz');
const res2 = await getPhotoRes(weird);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('image/jpeg');
});
it('extensionless files get image/jpeg, never a bare image/', async () => {
const id = await addPhoto('noext');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
});
@@ -0,0 +1,42 @@
/**
* Source-inspection contract test for #1078.
*
* POST /api/admin/thumbnails/regenerate-previews hands its selected rows to
* ensurePreviewImage, which branches on `source_origin` (and then reads
* `external_relpath` / `filename`) to reach an external/reference photo on its
* media mount. When the select list omitted those columns, every external row
* looked managed, resolvePhotoStorageKey returned null, and the endpoint
* reported success while silently generating nothing for reference galleries.
*/
const fs = require('fs');
const path = require('path');
describe('regenerate-previews selects the columns ensurePreviewImage branches on (#1078)', () => {
const src = fs.readFileSync(
path.join(__dirname, '..', '..', 'src', 'routes', 'adminThumbnails.js'),
'utf8',
);
// The select feeding the regenerate-previews handler, from the route
// declaration to the end of that statement.
const selectStatement = (() => {
const routeIdx = src.indexOf('/regenerate-previews');
expect(routeIdx).toBeGreaterThan(-1);
const selectIdx = src.indexOf('.select(', routeIdx);
expect(selectIdx).toBeGreaterThan(-1);
return src.slice(selectIdx, src.indexOf(';', selectIdx));
})();
it.each(['source_origin', 'external_relpath', 'filename'])(
'selects %s',
(column) => {
expect(selectStatement).toContain(`'${column}'`);
}
);
it('still selects the columns the managed path needs', () => {
for (const column of ['id', 'event_id', 'path', 'media_type', 'mime_type', 'preview_path']) {
expect(selectStatement).toContain(`'${column}'`);
}
});
});
@@ -135,9 +135,9 @@ function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
);
}
function signGalleryToken({ eventId = 100, eventSlug = 'wedding' } = {}) {
function signGalleryToken({ eventId = 100, eventSlug = 'wedding', ...extra } = {}) {
return jwt.sign(
{ eventId, eventSlug, type: 'gallery' },
{ eventId, eventSlug, type: 'gallery', ...extra },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
@@ -288,6 +288,56 @@ describe('GET /auth/session — symmetry with protected middleware', () => {
expect(res.body.valid).toBe(true);
});
/**
* What KIND of gallery session this is (#1149).
*
* The frontend used to keep this in sessionStorage, which is per-TAB while
* the cookie is per-browser: a gallery reopened in a second tab lost
* 'client' even though the backend still served it as one, and the UI hid
* the only control that clears the privileged cookie. Reported from the
* token so a restored session knows what it actually is.
*/
describe('gallery session kind', () => {
beforeEach(() => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: false,
expires_at: new Date(Date.now() + 86400_000),
});
});
it('reports a PIN-client session as client', async () => {
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${signGalleryToken({ accessLevel: 'client' })}`);
expect(res.body.valid).toBe(true);
expect(res.body.accessLevel).toBe('client');
expect(res.body.viaCustomer).toBe(false);
});
it('reports a customer-portal session, which looks like a guest', async () => {
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${signGalleryToken({ via: 'customer', customerId: 7 })}`);
expect(res.body.valid).toBe(true);
expect(res.body.accessLevel).toBe('guest');
expect(res.body.viaCustomer).toBe(true);
});
it('reports a plain guest as neither', async () => {
// The flags have to discriminate, or they would just hand every visitor
// a Logout button back.
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${signGalleryToken()}`);
expect(res.body.valid).toBe(true);
expect(res.body.accessLevel).toBe('guest');
expect(res.body.viaCustomer).toBe(false);
});
});
it('returns valid:false when the token is revoked', async () => {
fakeDb.adminUsers.push({
id: 1,
@@ -0,0 +1,211 @@
/**
* Authorization / ownership gaps (GHSA permission cluster):
* - jm7j: API-token list must scope to the caller (non-super sees only own)
* - gprq: API-token revoke must be owner-or-super_admin
* - 3rqx: event update must not mass-assign identity/secret columns
* - j2f4: category hero must belong to that category
*/
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-authz-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'authz-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-authz-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken,
} = require('../integration/helpers/crmDb');
describe('authorization / ownership gaps', () => {
let db; let cleanup; let app;
let superId; let superTok; let adminId; let adminTok;
const grantPermissionToRole = async (roleName, permName) => {
const role = await db('roles').where({ name: roleName }).first();
const perm = await db('permissions').where({ name: permName }).first();
const exists = await db('role_permissions')
.where({ role_id: role.id, permission_id: perm.id }).first();
if (!exists) {
await db('role_permissions').insert({ role_id: role.id, permission_id: perm.id });
}
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: superId } = await seedMinimal(db));
await assignAdminRole(db, superId, 'super_admin');
superTok = mintAdminToken(superId);
const pass = await bcrypt.hash('x', 4);
const ins = await db('admin_users').insert({
username: 'plain-admin', email: 'plain@example.com',
password_hash: pass, must_change_password: false, created_at: new Date(),
}).returning('id');
adminId = ins[0]?.id ?? ins[0];
await assignAdminRole(db, adminId, 'admin');
// Grant settings.edit to the admin role BEFORE any request populates the
// 60s permission cache, so the revoke test exercises the ownership check
// (404) rather than the missing-permission gate (403). This models a
// custom role that carries settings.edit — the scenario GHSA-gprq needs.
await grantPermissionToRole('admin', 'settings.edit');
adminTok = mintAdminToken(adminId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/api-tokens', require('../../src/routes/adminApiTokens'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/admin/categories', require('../../src/routes/adminCategories'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
describe('API tokens (jm7j / gprq)', () => {
let superTokenId;
beforeAll(async () => {
const res = await auth(request(app).post('/api/admin/api-tokens'), superTok)
.send({ name: 'super-token', scopes: ['read'] });
expect(res.status).toBe(201);
superTokenId = res.body.id;
});
it('non-super admin does not see another admin\'s tokens in the list', async () => {
const res = await auth(request(app).get('/api/admin/api-tokens'), adminTok);
expect(res.status).toBe(200);
expect(res.body.find((t) => t.id === superTokenId)).toBeUndefined();
});
it('super_admin sees all tokens', async () => {
const res = await auth(request(app).get('/api/admin/api-tokens'), superTok);
expect(res.status).toBe(200);
expect(res.body.find((t) => t.id === superTokenId)).toBeDefined();
});
it('a non-owner (with settings.edit) cannot revoke another admin\'s token', async () => {
const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), adminTok);
expect(res.status).toBe(404);
const row = await db('api_tokens').where({ id: superTokenId }).first();
expect(row.revoked_at).toBeFalsy();
});
it('the owner can revoke their own token', async () => {
const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), superTok);
expect(res.status).toBe(200);
});
});
describe('event update mass-assignment (3rqx)', () => {
it('ignores identity/secret columns in the request body', async () => {
const seedShareToken = 'orig-share-token';
const ins = await db('events').insert({
slug: 'authz-mass-assign', event_type: 'wedding', event_name: 'Before',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'orig-hash', share_link: '/gallery/authz/share', share_token: seedShareToken, expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const eventId = ins[0]?.id ?? ins[0];
const res = await auth(request(app).put(`/api/admin/events/${eventId}`), superTok).send({
event_name: 'After',
created_by: 99999,
slug: 'hijacked-slug',
share_token: 'hijacked-token',
password_hash: 'hijacked-hash',
is_archived: 1,
archive_path: '/hijacked/archive/path',
hero_logo_path: '/etc/passwd',
is_draft: 1,
project_id: 99999,
// Case-variant keys — SQLite matches columns case-insensitively.
Password_Hash: 'case-hijack-hash',
Created_By: 88888,
});
expect(res.status).toBe(200);
const row = await db('events').where({ id: eventId }).first();
expect(row.event_name).toBe('After'); // legit field applied
expect(row.created_by).toBe(superId); // ownership untouched (+ case-variant)
expect(row.slug).toBe('authz-mass-assign'); // routing identity untouched
expect(row.share_token).toBe(seedShareToken); // secret untouched
expect(row.password_hash).toBe('orig-hash'); // secret untouched (+ case-variant)
expect(row.is_archived).toBeFalsy(); // archive lifecycle untouched
expect(row.archive_path).toBeFalsy(); // forged archive path rejected
expect(row.hero_logo_path).toBeFalsy(); // fs.unlink primitive blocked
expect(row.is_draft).toBeFalsy(); // publish workflow not bypassed
expect(row.project_id).toBeFalsy(); // server-managed relationship untouched
});
it('returns 200 (no-op) when the body contains only protected fields', async () => {
const ins = await db('events').insert({
slug: 'authz-empty-update', event_type: 'wedding', event_name: 'Keep',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: '/gallery/authz-empty/share', share_token: 'authz-empty-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const id = ins[0]?.id ?? ins[0];
// Body reduces to {} after the denylist — must not 500 (Knex rejects
// .update({})).
const res = await auth(request(app).put(`/api/admin/events/${id}`), superTok)
.send({ created_by: 1, slug: 'x', is_archived: 1 });
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.event_name).toBe('Keep');
});
});
describe('category hero cross-category (j2f4)', () => {
it('rejects a hero photo that is not in the category', async () => {
const evIns = await db('events').insert({
slug: 'authz-cat', event_type: 'wedding', event_name: 'Cat Event',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: '/gallery/authz-cat/share', share_token: 'authz-cat-share', expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const evId = evIns[0]?.id ?? evIns[0];
const mkCat = async (name) => {
const c = await db('photo_categories').insert({
event_id: evId, name, slug: name.toLowerCase(), created_at: new Date().toISOString(),
}).returning('id');
return c[0]?.id ?? c[0];
};
const cat1 = await mkCat('Cat1');
const cat2 = await mkCat('Cat2');
const pIns = await db('photos').insert({
event_id: evId, filename: 'p.jpg', path: 'authz-cat/p.jpg', type: 'individual',
category_id: cat1, uploaded_at: new Date().toISOString(),
}).returning('id');
const photoInCat1 = pIns[0]?.id ?? pIns[0];
// Pointing cat2's hero at a photo that lives in cat1 must be refused.
const bad = await auth(request(app).put(`/api/admin/categories/${cat2}/hero`), superTok)
.send({ hero_photo_id: photoInCat1 });
expect(bad.status).toBe(404);
// The photo's own category accepts it.
const ok = await auth(request(app).put(`/api/admin/categories/${cat1}/hero`), superTok)
.send({ hero_photo_id: photoInCat1 });
expect(ok.status).toBe(200);
});
});
});
@@ -0,0 +1,95 @@
/**
* Full-instance export is super_admin only (GHSA-pv6w-rj34-wj9v).
*
* GET /api/admin/backup/picpeak/export dumps every table unredacted (bcrypt
* hashes, 2FA, SMTP/SSO/WhatsApp/webhook/S3 secrets). It was gated only by
* requirePermission('backup.create'), which the built-in `admin` role holds —
* so any non-super_admin admin could download the whole database. Pins that
* `admin` now gets 403 and `super_admin` passes the gate.
*/
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-bkexport-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bkexport-test-secret';
// The export otherwise walks the whole DB and writes a zip — stub it so the
// super_admin happy path is fast and deterministic; the gate is what's tested.
// The route deletes path.dirname(filePath) recursively after download, so the
// stub MUST live in its own dir — a bare os.tmpdir() file would make the route
// wipe the whole temp root (and other jest workers' DB files).
const mockExportDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-export-stub-'));
const mockExportPath = path.join(mockExportDir, 'export.picpeak');
fs.writeFileSync(mockExportPath, 'stub');
jest.mock('../../src/services/picpeakExportService', () => ({
createPicpeak: jest.fn(async () => ({ filePath: mockExportPath })),
}));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('backup export super_admin gate (GHSA-pv6w)', () => {
let db;
let cleanup;
let app;
let adminToken; let superToken;
const mkUser = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@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];
return jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
adminToken = await mkUser('limited-admin', 'admin');
superToken = await mkUser('root-admin', 'super_admin');
app = express();
app.use(express.json());
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
fs.rmSync(mockExportDir, { recursive: true, force: true });
});
it('denies the built-in admin role (was: full DB dump)', async () => {
const res = await request(app)
.get('/api/admin/backup/picpeak/export')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(403);
});
it('allows super_admin', async () => {
const res = await request(app)
.get('/api/admin/backup/picpeak/export')
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).not.toBe(403);
expect(res.status).toBeLessThan(500);
});
});
@@ -0,0 +1,233 @@
/**
* Dashboard endpoints must not leak other admins' data to event-scoped
* editors — GHSA-c2jj (/stats), GHSA-gqx7 (/analytics), GHSA-jhcf (/activity).
*
* All three are gated only by `analytics.view`, which the `editor` role holds.
* But the events LIST restricts editors to their own rows
* (adminEvents/crud.js: roleName === 'editor' → created_by = admin.id), so an
* editor saw instance-wide totals — and, via /analytics topGalleries, other
* admins' gallery names and SLUGS (the public gallery URL component) — for
* events invisible to them everywhere else.
*
* Scoping deliberately keys on `editor` to mirror the events list exactly, so
* the `admin` role's dashboard is unchanged.
*/
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-dashscope-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dashscope-test-secret';
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('dashboard scoping (GHSA-c2jj / gqx7 / jhcf)', () => {
let db; let cleanup; let app;
let editorToken; let superToken;
let ownEventId; let foreignEventId;
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@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];
const token = jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
return { id, token };
};
const mkEvent = async (slug, createdBy) => {
const r = await db('events').insert({
slug,
event_type: 'wedding',
event_name: `${slug}-name`,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: `tok-${slug}`,
share_link: `/gallery/${slug}/tok-${slug}`,
created_by: createdBy,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const editor = await mkAdmin('scoped-editor', 'editor');
const sup = await mkAdmin('root-admin', 'super_admin');
editorToken = editor.token;
superToken = sup.token;
ownEventId = await mkEvent('own-gallery', editor.id);
foreignEventId = await mkEvent('foreign-gallery', sup.id);
// One photo + one view per event so the aggregates are non-zero.
for (const [eventId, name] of [[ownEventId, 'own'], [foreignEventId, 'foreign']]) {
await db('photos').insert({
event_id: eventId,
filename: `${name}.jpg`,
path: `events/active/${name}.jpg`,
type: 'individual',
size_bytes: 1000,
uploaded_at: new Date().toISOString(),
});
await db('access_logs').insert({
event_id: eventId,
action: 'view',
ip_address: `10.0.0.${eventId}`,
user_agent: 'Mozilla/5.0',
timestamp: new Date().toISOString(),
});
await db('activity_logs').insert({
activity_type: 'photo_viewed',
actor_type: 'admin',
actor_name: `${name}-actor`,
event_id: eventId,
created_at: new Date().toISOString(),
});
}
app = express();
app.use(express.json());
app.use('/api/admin/dashboard', require('../../src/routes/adminDashboard'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('/stats counts only the editor\'s own events and photos', async () => {
const res = await request(app)
.get('/api/admin/dashboard/stats')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
expect(Number(res.body.totalEvents)).toBe(1);
expect(Number(res.body.totalPhotos)).toBe(1);
// The catalogued original bytes — this is what carries the per-event
// scoping, and what `storageUsed` reported before #1164.
expect(Number(res.body.catalogedBytes)).toBe(1000);
});
it('/stats reports disk usage unscoped, because disk is not per-event', async () => {
// storageUsed is a measurement of the storage root (#1164), so it is the
// same number for every admin by design. Pinned so a future reviewer
// reading "everything on this endpoint is scoped" does not turn it into a
// sum of this editor's photos again — which is the bug that was fixed.
const res = await request(app)
.get('/api/admin/dashboard/stats')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
expect(res.body.storageUsed).not.toBe(1000);
expect(res.body).toHaveProperty('storageBreakdown');
});
it('/stats reports the catalogued figure on an S3 backend, not a near-zero disk walk', async () => {
// STORAGE_PATH holds only incidental local files when objects live in a
// bucket, so walking it would report near-zero and drag the soft-limit
// recommendation with it.
const prev = process.env.STORAGE_BACKEND;
process.env.STORAGE_BACKEND = 's3';
try {
const res = await request(app)
.get('/api/admin/dashboard/stats')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
expect(res.body.storageUsed).toBeNull();
expect(res.body.storageMeasurement).toBe('catalog');
expect(Number(res.body.catalogedBytes)).toBe(1000);
} finally {
if (prev === undefined) delete process.env.STORAGE_BACKEND;
else process.env.STORAGE_BACKEND = prev;
}
});
it('/analytics does not expose a foreign gallery name or slug', async () => {
const res = await request(app)
.get('/api/admin/dashboard/analytics?days=7')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const body = JSON.stringify(res.body);
expect(body).not.toContain('foreign-gallery');
expect(body).not.toContain('foreign-gallery-name');
expect(res.body.topGalleries.map((g) => g.slug)).toEqual(['own-gallery']);
});
it('/activity does not surface a foreign event\'s entries', async () => {
const res = await request(app)
.get('/api/admin/dashboard/activity')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const actors = res.body.map((a) => a.actorName);
expect(actors).toContain('own-actor');
expect(actors).not.toContain('foreign-actor');
});
it('leaves super_admin unscoped across all three', async () => {
const stats = await request(app)
.get('/api/admin/dashboard/stats')
.set('Authorization', `Bearer ${superToken}`);
expect(Number(stats.body.totalEvents)).toBe(2);
const analytics = await request(app)
.get('/api/admin/dashboard/analytics?days=7')
.set('Authorization', `Bearer ${superToken}`);
expect(analytics.body.topGalleries.map((g) => g.slug).sort())
.toEqual(['foreign-gallery', 'own-gallery']);
const activity = await request(app)
.get('/api/admin/dashboard/activity')
.set('Authorization', `Bearer ${superToken}`);
expect(activity.body.map((a) => a.actorName)).toContain('foreign-actor');
});
});
/**
* Codex round 2: the /activity filter trusts `activity_logs.event_id`, but
* expenseService was passing `adminId` into logActivity's third positional
* parameter — which is `eventId`. Admin and event id sequences overlap, so a
* foreign admin's expense metadata could surface under an editor's event.
* Those writers now pass the actor instead, leaving event_id NULL.
*/
describe('activity writers do not put admin ids in event_id (GHSA-jhcf)', () => {
it('expenseService passes the actor, not adminId, as the event id', () => {
const fs2 = require('fs');
const src = fs2.readFileSync(
require('path').join(__dirname, '../../src/services/expenseService.js'), 'utf8',
);
// No logActivity call may end with a bare `, adminId)` — that slot is eventId.
const offenders = src.split('\n').filter(
(l) => l.includes('logActivity(') && /,\s*adminId\s*\)/.test(l),
);
expect(offenders).toEqual([]);
// And the actor form must actually be in use.
expect(src).toContain("{ type: 'admin', id: adminId }");
});
});
@@ -0,0 +1,120 @@
/**
* Manual database backup must not honour a caller-supplied destination
* (GHSA-jw8m-43r2-jqrm).
*
* POST /api/admin/database-backup/backup forwarded req.body straight into
* databaseBackupService.backup(), which merges options over its config:
* const { destinationPath = '/backup/database', ... } = { ...config, ...options }
* `destinationPath` is not a persistable setting (the /config allowlist only
* accepts `database_backup_*` keys), so the request body was its ONLY source.
*
* The `admin` role holds backup.create but neither settings.edit nor
* backup.restore — so it could aim a full DB dump (bcrypt hashes, gallery
* password hashes, encrypted SMTP creds) at the PUBLIC /uploads static mount
* (server.js mounts it with no auth middleware) and fetch it unauthenticated.
*
* Pins that destinationPath from the body is ignored, while the legitimate
* knobs still pass through.
*/
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-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dbbackup-test-secret';
// Capture what the route hands the service; never run a real backup.
const mockBackup = jest.fn(async () => ({ success: true }));
jest.mock('../../src/services/databaseBackup', () => ({
databaseBackupService: {
get isRunning() { return false; },
backup: (...args) => mockBackup(...args),
},
}));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('manual database backup destination (GHSA-jw8m)', () => {
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@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(); });
beforeEach(() => mockBackup.mockClear());
it('ignores a caller-supplied destinationPath', async () => {
const res = await request(app)
.post('/api/admin/database-backup/backup')
.set('Authorization', `Bearer ${adminToken}`)
.send({ destinationPath: '/app/storage/uploads' });
expect(res.status).toBe(200);
// Give the fire-and-forget call a tick to land.
await new Promise((resolve) => setImmediate(resolve));
expect(mockBackup).toHaveBeenCalled();
const opts = mockBackup.mock.calls[0][0];
expect(opts).not.toHaveProperty('destinationPath');
expect(JSON.stringify(opts)).not.toContain('uploads');
});
it('still forwards the legitimate backup knobs', async () => {
const res = await request(app)
.post('/api/admin/database-backup/backup')
.set('Authorization', `Bearer ${adminToken}`)
.send({ compress: false, validateIntegrity: false, destinationPath: '/tmp/evil' });
expect(res.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
const opts = mockBackup.mock.calls[0][0];
expect(opts.compress).toBe(false);
expect(opts.validateIntegrity).toBe(false);
expect(opts).not.toHaveProperty('destinationPath');
});
it('omits absent knobs entirely so service/config defaults still apply', async () => {
const res = await request(app)
.post('/api/admin/database-backup/backup')
.set('Authorization', `Bearer ${adminToken}`)
.send({});
expect(res.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
// An explicit `{compress: undefined}` would override config on spread —
// absent keys must simply not be present.
expect(mockBackup.mock.calls[0][0]).toEqual({});
});
});
@@ -0,0 +1,101 @@
/**
* GHSA-2qc2 / GHSA-32h4 / GHSA-3335 — feedback moderation, deletion, and the
* pending-moderation list are by-feedback-id (or global) and lacked ownership
* scoping, so a restricted editor could act on / enumerate feedback for events
* it does not own. super_admin keeps global access.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fbown-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'fbown-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fbown-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
describe('feedback ownership scoping', () => {
let db; let cleanup; let app;
let superTok; let editorTok; let editorId;
let foreignFeedbackId;
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId: superId } = await seedMinimal(db);
await assignAdminRole(db, superId, 'super_admin');
superTok = mintAdminToken(superId);
const ins = await db('admin_users').insert({
username: 'editor', email: 'editor@example.com',
password_hash: await bcrypt.hash('x', 4), must_change_password: false, created_at: new Date(),
}).returning('id');
editorId = ins[0]?.id ?? ins[0];
await assignAdminRole(db, editorId, 'editor');
editorTok = mintAdminToken(editorId);
// Event owned by super_admin (NOT the editor).
const ev = await db('events').insert({
slug: 'fbown-foreign', event_type: 'wedding', event_name: 'Foreign',
event_date: '2026-08-01', host_email: 'h@e.com', admin_email: 'a@e.com',
password_hash: 'x', share_link: '/g/fbown/s', share_token: 'fbown-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const eventId = ev[0]?.id ?? ev[0];
const ph = await db('photos').insert({
event_id: eventId, filename: 'p.jpg', path: 'fbown-foreign/p.jpg', type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
const photoId = ph[0]?.id ?? ph[0];
const fb = await db('photo_feedback').insert({
photo_id: photoId, event_id: eventId, feedback_type: 'comment',
comment_text: 'hi', is_approved: 0, is_hidden: 0, created_at: new Date().toISOString(),
}).returning('id');
foreignFeedbackId = fb[0]?.id ?? fb[0];
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/feedback', require('../../src/routes/adminFeedback'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('editor cannot moderate feedback on an event it does not own (404)', async () => {
const res = await auth(request(app).put(`/api/admin/feedback/feedback/${foreignFeedbackId}/approve`), editorTok);
expect(res.status).toBe(404);
const row = await db('photo_feedback').where({ id: foreignFeedbackId }).first();
expect([false, 0]).toContain(row.is_approved); // untouched
});
it('editor cannot delete foreign feedback, row survives', async () => {
const res = await auth(request(app).delete(`/api/admin/feedback/feedback/${foreignFeedbackId}`), editorTok);
// Denied either at the events.delete permission layer (editor lacks it →
// 403) or the ownership layer (404) — both must leave the row intact.
expect([403, 404]).toContain(res.status);
expect(await db('photo_feedback').where({ id: foreignFeedbackId }).first()).toBeDefined();
});
it('editor sees no foreign feedback in pending-moderation', async () => {
const res = await auth(request(app).get('/api/admin/feedback/feedback/pending-moderation'), editorTok);
expect(res.status).toBe(200);
expect(res.body.find((f) => f.id === foreignFeedbackId)).toBeUndefined();
});
it('super_admin CAN moderate and see it', async () => {
const pending = await auth(request(app).get('/api/admin/feedback/feedback/pending-moderation'), superTok);
expect(pending.body.find((f) => f.id === foreignFeedbackId)).toBeDefined();
const res = await auth(request(app).put(`/api/admin/feedback/feedback/${foreignFeedbackId}/approve`), superTok);
expect(res.status).toBe(200);
});
});
@@ -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,87 @@
/**
* GHSA-rh8r-7x3h-36rv — the unauthenticated GET /api/gallery/resolve/:identifier
* must NOT return a gallery's secret share_token (nor the share links that
* embed it) for a bare *slug* lookup. Slugs appear in gallery URLs and are
* guessable; handing back the secret turns a known slug into share-link
* access to a no-password gallery. The token is only returned when the caller
* resolved via the token / full share link (i.e. already holds it).
*/
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-resolve-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'resolve-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-resolve-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'resolve-test-event';
const SHARE_TOKEN = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6';
describe('GET /api/gallery/resolve/:identifier (GHSA-rh8r)', () => {
let db; let cleanup; let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Resolve Test',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/${SHARE_TOKEN}`,
share_token: SHARE_TOKEN,
require_password: 0, // no-password → the token IS the access credential
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
});
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('does NOT leak the share_token (or share links) for a bare slug lookup', async () => {
const res = await request(app).get(`/api/gallery/resolve/${SLUG}`);
expect(res.status).toBe(200);
expect(res.body.slug).toBe(SLUG);
expect(res.body.matchType).toBe('slug');
// The secret must be absent — and must not sneak out via the share links.
expect(res.body.token).toBeUndefined();
expect(res.body.share_link).toBeUndefined();
expect(res.body.share_url).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain(SHARE_TOKEN);
});
it('DOES return the token when the caller already resolved via the token', async () => {
const res = await request(app).get(`/api/gallery/resolve/${SHARE_TOKEN}`);
expect(res.status).toBe(200);
expect(res.body.token).toBe(SHARE_TOKEN);
expect(res.body.matchType).toMatch(/token/);
});
it('does NOT leak the token via SQL LIKE wildcards in the link_partial fallback', async () => {
// Before the escaping fix, an anonymous request of 32 underscores matched
// any share_link ending in a 32-char token (`_` = single-char wildcard),
// resolved as matchType 'link_partial', and handed back the bearer token.
// The share_token here has no underscores, so an escaped LIKE must miss.
const res = await request(app).get(`/api/gallery/resolve/${'_'.repeat(SHARE_TOKEN.length)}`);
expect(res.status).toBe(404);
expect(res.body.token).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain(SHARE_TOKEN);
});
});
@@ -0,0 +1,188 @@
/**
* SQLite boolean coercion in the guest gallery surface (#1028).
*
* SQLite stores booleans as 0/1; Postgres stores true/false. The /photos
* payload and every download guard compared strictly against `true`/`false`,
* so on SQLite:
*
* allow_downloads: 0 !== false → true (button shown while disabled)
* allow_user_uploads: 1 === true → false (button hidden while enabled)
* if (allow_downloads === false) → never fires, so ALL download endpoints
* kept serving with downloads switched off
*
* (The download-jobs route asserted on main is #858, which is beta-only —
* this branch covers the three download endpoints that exist here.)
*
* The harness runs on SQLite, so these assertions exercise the real engine
* values rather than a mock. Every test here fails on the unfixed code.
*/
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-sqlite-flags-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sqlite-flags-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sqlite-flags-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'sqlite-flags-gallery';
describe('gallery flags survive SQLite 0/1 storage (#1028)', () => {
let db; let cleanup; let app; let eventId; let photoId;
async function setEventFlags(patch) {
await db('events').where('id', eventId).update(patch);
}
async function getPayload() {
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
expect(res.status).toBe(200);
return res.body.event;
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const ev = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'SQLite Flags',
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: 'sqlite-flags-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
// Password-free so verifyGalleryAccess takes the public path and loads
// the row with SELECT * — i.e. the raw 0/1 values, same as production.
require_password: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = ev[0]?.id ?? ev[0];
const ph = await db('photos').insert({
event_id: eventId,
filename: 'p.jpg',
path: `${SLUG}/p.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoId = ph[0]?.id ?? ph[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(); });
test('the engine under test really is SQLite storing 0/1', async () => {
expect(['sqlite3', 'better-sqlite3']).toContain(db.client.config.client);
await setEventFlags({ allow_downloads: 0 });
const row = await db('events').where('id', eventId).first('allow_downloads');
expect(row.allow_downloads).toBe(0);
});
describe('with downloads disabled (allow_downloads = 0)', () => {
beforeAll(async () => {
await setEventFlags({ allow_downloads: 0, allow_user_uploads: 1 });
});
test('payload reports allow_downloads false (was true — header button shown)', async () => {
expect((await getPayload()).allow_downloads).toBe(false);
});
test('payload reports allow_user_uploads true (was false — upload button hidden)', async () => {
expect((await getPayload()).allow_user_uploads).toBe(true);
});
test('single-photo download is refused', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(res.status).toBe(403);
});
test('download-all is refused', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`);
expect(res.status).toBe(403);
});
test('download-selected is refused', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.send({ photo_ids: [photoId] });
expect(res.status).toBe(403);
});
});
describe('with downloads enabled (allow_downloads = 1)', () => {
beforeAll(async () => {
await setEventFlags({ allow_downloads: 1, allow_user_uploads: 0 });
});
test('payload reports allow_downloads true / allow_user_uploads false', async () => {
const event = await getPayload();
expect(event.allow_downloads).toBe(true);
expect(event.allow_user_uploads).toBe(false);
});
test('download-all is no longer refused', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`);
expect(res.status).not.toBe(403);
});
});
describe('protection flags', () => {
test('0/1 protection toggles are reported the way they are stored', async () => {
await setEventFlags({
disable_right_click: 1,
enable_devtools_protection: 1,
use_canvas_rendering: 1,
watermark_downloads: 1,
overlay_protection: 0,
});
const event = await getPayload();
expect(event.disable_right_click).toBe(true);
expect(event.enable_devtools_protection).toBe(true);
expect(event.use_canvas_rendering).toBe(true);
expect(event.watermark_downloads).toBe(true);
expect(event.overlay_protection).toBe(false);
});
});
describe('per-category download blocking (#640) on SQLite', () => {
test('a category with allow_downloads = 0 is reported as blocked', async () => {
const cat = await db('photo_categories').insert({
name: 'Blocked', slug: 'blocked', event_id: eventId, is_global: 0, allow_downloads: 0,
}).returning('id');
const categoryId = cat[0]?.id ?? cat[0];
await db('photos').where('id', photoId).update({ category_id: categoryId });
await setEventFlags({ allow_downloads: 1 });
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
expect(res.status).toBe(200);
const category = res.body.categories.find((c) => c.id === categoryId);
expect(category.allow_downloads).toBe(false);
const photo = res.body.photos.find((p) => p.id === photoId);
expect(photo.category_allow_downloads).toBe(false);
// …and the per-category guard on the single-photo route fires.
const dl = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(dl.status).toBe(403);
});
});
});
@@ -0,0 +1,170 @@
/**
* "Date Taken" ordering across SQLite's storage classes (#1172).
*
* photos.captured_at does not hold one type on SQLite. Three writers put three
* different things in it:
*
* integer managed uploads — photoProcessor.js:441 hands knex a Date, which
* the sqlite3 binding stores as epoch milliseconds
* text external imports and the capture-date backfill, which write
* ISO-8601 ('2026-06-03T01:15:00.000Z')
* null no capture date, so the sort falls through to uploaded_at —
* itself text, in knex's 'YYYY-MM-DD HH:MM:SS' shape
*
* A plain COALESCE over that mixture is not an ordering. SQLite sorts INTEGER
* before TEXT unconditionally, so every managed photo carrying EXIF came back
* ahead of every photo that did not, whatever the dates said. And among the
* text values 'T' (0x54) outranks the space (0x20), so a same-day ISO 01:15
* sorted behind a fallback 23:00.
*
* Both failures predate #1172 — the first needs only two managed photos — but
* the sort is what that issue is about, so they are fixed and pinned here.
* Every test below fails on the unfixed ORDER BY.
*/
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-capsort-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'capsort-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-capsort-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'capsort-gallery';
describe('capture-date ordering on SQLite (#1172)', () => {
let db; let cleanup; let app; let eventId;
// Managed uploads store an epoch-millisecond INTEGER, because
// photoProcessor.js:441 hands knex a Date and the sqlite3 binding converts
// it. That conversion cannot be reproduced from inside jest — there the
// binding's type dispatch misses sandbox-created Dates and writes the string
// "[object Object]" instead (CLAUDE.md). Verified outside jest: a Date lands
// as {"c":1830211200000,"ty":"integer"}. So these tests write the integer
// production would have written, rather than a Date that jest mangles.
const managed = (iso) => new Date(iso).getTime();
const addPhoto = async (filename, capturedAt, uploadedAt) => {
const row = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
captured_at: capturedAt,
uploaded_at: uploadedAt,
}).returning('id');
return row[0]?.id ?? row[0];
};
const orderedFilenames = async (order = 'asc') => {
const res = await request(app).get(`/api/gallery/${SLUG}/photos?sort=capture_date&order=${order}`);
expect(res.status).toBe(200);
return res.body.photos.map((p) => p.filename);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const ev = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Capture Sort',
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: 'capsort-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
require_password: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = ev[0]?.id ?? ev[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(async () => { await db('photos').where({ event_id: eventId }).del(); });
test('the fixture really does put three storage classes in one column', async () => {
expect(['sqlite3', 'better-sqlite3']).toContain(db.client.config.client);
await addPhoto('m.jpg', managed('2026-06-03T01:15:00Z'), '2026-01-01 00:00:00');
await addPhoto('e.jpg', '2020-01-01T00:00:00.000Z', '2026-01-01 00:00:00');
await addPhoto('n.jpg', null, '2026-01-01 00:00:00');
const rows = await db.raw('select filename, typeof(captured_at) as t from photos order by filename');
const byName = Object.fromEntries((rows.rows || rows).map((r) => [r.filename, r.t]));
// Exactly the mixture that made COALESCE meaningless.
expect(byName).toEqual({ 'm.jpg': 'integer', 'e.jpg': 'text', 'n.jpg': 'null' });
});
test('a managed EXIF date does not outrank an earlier one stored as text', async () => {
// The pre-existing failure, reachable with managed photos alone: integer
// beat text regardless of the dates, so this came back exactly reversed.
await addPhoto('managed-2027.jpg', managed('2027-12-31T00:00:00Z'), '2026-01-01 00:00:00');
await addPhoto('external-2020.jpg', '2020-01-01T00:00:00.000Z', '2026-01-01 00:00:00');
expect(await orderedFilenames('asc')).toEqual(['external-2020.jpg', 'managed-2027.jpg']);
expect(await orderedFilenames('desc')).toEqual(['managed-2027.jpg', 'external-2020.jpg']);
});
test('a photo with no capture date sorts by its upload time, not ahead of everything', async () => {
await addPhoto('has-exif-2027.jpg', managed('2027-12-31T00:00:00Z'), '2027-12-31 00:00:00');
await addPhoto('no-exif-2020.jpg', null, '2020-01-01 00:00:00');
expect(await orderedFilenames('asc')).toEqual(['no-exif-2020.jpg', 'has-exif-2027.jpg']);
});
test('an ISO capture time and a fallback upload time compare by clock, not by separator', async () => {
// Same day: 'T' vs ' ' decided this before, so 01:15 sorted after 23:00.
await addPhoto('iso-0115.jpg', '2026-06-03T01:15:00.000Z', '2026-06-03 05:00:00');
await addPhoto('fallback-2300.jpg', null, '2026-06-03 23:00:00');
expect(await orderedFilenames('asc')).toEqual(['iso-0115.jpg', 'fallback-2300.jpg']);
});
test('an epoch-integer uploaded_at is compared as a date, not as its digits', async () => {
// uploaded_at is not always text either: a legacy archive restore leaves
// epoch milliseconds in it (a .picpeak restore from an install that stored them that way).
// Reading that with substr() would have compared the string '1830297600000'
// against '2020-01-01 00:00:00', putting the 2028 row first.
await addPhoto('epoch-upload-2028.jpg', null, new Date('2028-01-01T00:00:00Z').getTime());
await addPhoto('captured-2020.jpg', managed('2020-01-01T00:00:00Z'), '2020-01-01 00:00:00');
const [row] = await db.raw('select typeof(uploaded_at) as t from photos where filename = \'epoch-upload-2028.jpg\'');
expect((row.t || row).toString()).toBe('integer');
expect(await orderedFilenames('asc')).toEqual(['captured-2020.jpg', 'epoch-upload-2028.jpg']);
});
test('all three storage classes order together correctly', async () => {
await addPhoto('c-managed-2026-08.jpg', managed('2026-08-15T12:00:00Z'), '2026-09-01 00:00:00');
await addPhoto('a-external-2026-06.jpg', '2026-06-03T01:15:00.000Z', '2026-09-01 00:00:00');
await addPhoto('d-fallback-2026-09.jpg', null, '2026-09-01 00:00:00');
await addPhoto('b-managed-2026-07.jpg', managed('2026-07-04T09:30:00Z'), '2026-09-01 00:00:00');
expect(await orderedFilenames('asc')).toEqual([
'a-external-2026-06.jpg',
'b-managed-2026-07.jpg',
'c-managed-2026-08.jpg',
'd-fallback-2026-09.jpg',
]);
});
});
@@ -0,0 +1,240 @@
/**
* Hidden/client-only photo access control across the bulk + secure photo
* routes (GHSA cluster: fpwq / ghf8 / 3jvw / 9cc4 / 2hqg / jc22).
*
* A photo with visibility='hidden' is client-only. The main photo-list and
* single-photo download/view routes enforced this, but the bulk-download,
* protected-image, and secure-image routes shipped without the check —
* letting an ordinary guest reach hidden photos. These tests pin that
* guests are refused and PIN-clients (accessLevel='client') still succeed.
*/
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-hidden-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-photo-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-hidden-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'hidden-photo-test-event';
describe('hidden-photo access control (GHSA cluster)', () => {
let db;
let cleanup;
let app;
let eventId;
let visibleId;
let hiddenId;
const guestToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const clientToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery', accessLevel: 'client' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Hidden Photo Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'hidden-photo-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const photoDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(photoDir, { recursive: true });
// A real 1x1 PNG so the protected /view route's Sharp processing path
// succeeds (fake bytes 500 on metadata()). Content, not extension,
// drives Sharp's format detection.
const PNG_1x1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMCAQGV2rY9AAAAAElFTkSuQmCC',
'base64'
);
const mkPhoto = async (filename, visibility) => {
fs.writeFileSync(path.join(photoDir, filename), PNG_1x1);
const p = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
visibility,
uploaded_at: new Date().toISOString(),
}).returning('id');
return p[0]?.id ?? p[0];
};
visibleId = await mkPhoto('visible.jpg', 'visible');
hiddenId = await mkPhoto('hidden.jpg', 'hidden');
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/images', require('../../src/routes/protectedImages'));
app.use('/api/secure-images', require('../../src/routes/secureImages'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('download-selected (GHSA-ghf8, medium)', () => {
it('omits a hidden photo for a guest even when its id is requested', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${guestToken()}`)
.send({ photo_ids: [visibleId, hiddenId] });
// The visible photo still zips; the hidden one is filtered out. If
// only the hidden id were requested, the filter empties the set → 404.
expect(res.status).toBe(200);
const solo = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${guestToken()}`)
.send({ photo_ids: [hiddenId] });
expect(solo.status).toBe(404);
});
it('includes the hidden photo for a client', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${clientToken()}`)
.send({ photo_ids: [hiddenId] });
expect(res.status).toBe(200);
});
});
describe('download-all (GHSA-fpwq, medium)', () => {
it('streams for a guest without erroring (hidden photos filtered)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download-all`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(200);
});
});
describe('protected-image view (GHSA-9cc4)', () => {
it('403s a hidden photo for a guest', async () => {
const res = await request(app)
.get(`/api/images/${SLUG}/photo/${hiddenId}/view`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(403);
});
it('serves a visible photo for a guest', async () => {
const res = await request(app)
.get(`/api/images/${SLUG}/photo/${visibleId}/view`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(200);
});
it('serves a hidden photo for a client', async () => {
const res = await request(app)
.get(`/api/images/${SLUG}/photo/${hiddenId}/view`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(res.status).toBe(200);
});
});
describe('signed-URL mint (GHSA-3jvw)', () => {
it('403s minting a signed URL for a hidden photo as a guest', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-url`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(403);
});
it('mints for a client', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-url`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(res.status).toBe(200);
expect(res.body.url).toContain('/signed/');
});
});
describe('legacy secure-token mint (protectedImages generate-secure-token)', () => {
it('403s a hidden photo for a guest', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-secure-token`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(403);
});
it('mints for a client', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-secure-token`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
});
});
describe('secure-token mint (GHSA-2hqg)', () => {
it('403s minting a secure token for a hidden photo as a guest', async () => {
const res = await request(app)
.post(`/api/secure-images/${SLUG}/generate-token`)
.set('Authorization', `Bearer ${guestToken()}`)
.send({ photoId: hiddenId });
expect(res.status).toBe(403);
});
it('mints for a client', async () => {
const res = await request(app)
.post(`/api/secure-images/${SLUG}/generate-token`)
.set('Authorization', `Bearer ${clientToken()}`)
.send({ photoId: hiddenId });
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
});
});
// A capability minted while a photo is visible must stop serving once the
// photo is hidden — unless minted by a client (clientBypass in the token).
describe('signed-URL TOCTOU (hidden AFTER minting)', () => {
afterEach(async () => {
await db('photos').where({ id: visibleId }).update({ visibility: 'visible' });
});
it("a guest's pre-minted signed URL stops serving once the photo is hidden", async () => {
const mint = await request(app)
.post(`/api/images/${SLUG}/photo/${visibleId}/generate-url`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(mint.status).toBe(200);
const url = mint.body.url;
// Still visible → serves.
expect((await request(app).get(url)).status).toBe(200);
// Hide it → the guest token (no clientBypass) must now be refused.
await db('photos').where({ id: visibleId }).update({ visibility: 'hidden' });
expect((await request(app).get(url)).status).toBe(403);
});
it("a client's pre-minted signed URL keeps serving after the photo is hidden", async () => {
const mint = await request(app)
.post(`/api/images/${SLUG}/photo/${visibleId}/generate-url`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(mint.status).toBe(200);
const url = mint.body.url;
await db('photos').where({ id: visibleId }).update({ visibility: 'hidden' });
expect((await request(app).get(url)).status).toBe(200);
});
});
});
@@ -0,0 +1,123 @@
/**
* Logo diagnostic must not leak the filesystem layout, and must mirror what
* resolveLogoFile actually tries (GHSA-29vm, codex round 2).
*
* Round 1 relativised `resolvedTo` and the candidate paths but still echoed
* `sources[].value` verbatim — and branding_logo_path is stored ABSOLUTE by
* multer, so the layout went out anyway. It also dropped the raw-absolute
* candidate, which the resolver retains (subject to containment), making the
* diagnostic report every candidate as missing for a legitimately contained
* absolute logo while `resolvedTo` named the 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-logodiag-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'logodiag-test-secret';
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('logo diagnostic disclosure (GHSA-29vm)', () => {
let db; let cleanup; let app; let token;
// bootCrmDb() sets STORAGE_PATH itself, so resolve these AFTER it runs.
let STORAGE; let logoDir; let logoPath;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// A legitimately contained absolute logo in a NON-standard storage subdir.
STORAGE = process.env.STORAGE_PATH;
logoDir = path.join(STORAGE, 'custom');
logoPath = path.join(logoDir, 'logo.png');
fs.mkdirSync(logoDir, { recursive: true });
fs.writeFileSync(logoPath, 'png');
const setting = { setting_key: 'branding_logo_path', setting_value: JSON.stringify(logoPath), setting_type: 'branding' };
const existing = await db('app_settings').where({ setting_key: 'branding_logo_path' }).first();
if (existing) await db('app_settings').where({ setting_key: 'branding_logo_path' }).update(setting);
else await db('app_settings').insert(setting);
const role = await db('roles').where({ name: 'super_admin' }).first();
const r = await db('admin_users').insert({
username: 'diag-admin', email: 'diag@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];
token = jwt.sign(
{ id, username: 'diag-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET, { expiresIn: '1h', issuer: 'picpeak-auth' },
);
app = express();
app.use(express.json());
app.use('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('does not leak absolute paths, cwd or storage root anywhere in the payload', async () => {
const res = await request(app)
.get('/api/admin/business-profile/logo-diagnostic')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
const body = JSON.stringify(res.body);
expect(body).not.toContain(STORAGE);
expect(body).not.toContain(process.cwd());
expect(res.body.storageRoot).toBeUndefined();
expect(res.body.cwd).toBeUndefined();
});
it('still finds a contained absolute logo outside the standard subdirs', async () => {
const res = await request(app)
.get('/api/admin/business-profile/logo-diagnostic')
.set('Authorization', `Bearer ${token}`);
const source = res.body.sources.find((s) => s.label === 'app_settings.branding_logo_path');
expect(source).toBeTruthy();
// The resolver keeps the contained absolute candidate, so the diagnostic
// must show it existing rather than reporting everything missing.
expect(source.candidates.some((c) => c.exists)).toBe(true);
expect(res.body.resolvedTo).toMatch(/^<STORAGE>\//);
});
it('shows the <STORAGE>/<value> candidate for a ROOT-RELATIVE logo URL (round 3)', async () => {
// `/custom/logo.png` is a URL, not a disk path, but path.isAbsolute() says
// true for both. Gating the stripped joins on isAbsolute() therefore hid
// `<STORAGE>/custom/logo.png` — a candidate resolveLogoFile does try and
// can resolve — so the diagnostic claimed nothing existed for a logo that
// renders fine, and collapsed the configured value to its basename.
await db('app_settings').where({ setting_key: 'branding_logo_path' })
.update({ setting_value: JSON.stringify('/custom/logo.png') });
const res = await request(app)
.get('/api/admin/business-profile/logo-diagnostic')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
const source = res.body.sources.find((s) => s.label === 'app_settings.branding_logo_path');
expect(source.candidates.some((c) => c.path === '<STORAGE>/custom/logo.png' && c.exists)).toBe(true);
// …and the disclosure guarantee still holds for this shape.
const body = JSON.stringify(res.body);
expect(body).not.toContain(STORAGE);
expect(body).not.toContain(process.cwd());
await db('app_settings').where({ setting_key: 'branding_logo_path' })
.update({ setting_value: JSON.stringify(logoPath) });
});
});
@@ -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);
});
});
@@ -0,0 +1,312 @@
/**
* Per-photo engagement counters (#895).
*
* Pins the contract that the admin EVENT > IMAGES table depends on:
* - photos.view_count increments when the full-size photo is served
* (it existed in the schema + admin UI but had NO writer at all)
* - the slideshow kiosk never increments views (migration 138 design)
* - single-photo downloads increment download_count (regression pin)
* - zip downloads (download-all, download-selected) increment
* download_count for the contained photos — previously they didn't,
* so zip-heavy galleries showed 0 per-photo downloads forever
* - the admin event-detail total_downloads counts singles AND zips
* (it counted action='download' only, disagreeing with the dashboard)
*/
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-engagement-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'engagement-test-secret';
// Real files on disk so /photo and the zip routes actually stream bytes.
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-engagement-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'engagement-test-event';
describe('photo engagement counters (#895)', () => {
let db;
let cleanup;
let app;
let eventId;
let photoIds;
let adminToken;
const galleryToken = (extra = {}) => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery', ...extra },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const getPhoto = async (id) => db('photos').where('id', id).first();
// The counter writes are fire-and-forget on purpose — give the event
// loop a beat before asserting.
const settle = () => new Promise((r) => setTimeout(r, 400));
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Engagement Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'engagement-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const photoDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(photoDir, { recursive: true });
photoIds = [];
for (let i = 0; i < 3; i++) {
const filename = `photo-${i}.jpg`;
fs.writeFileSync(path.join(photoDir, filename), Buffer.from(`fake-jpeg-bytes-${i}`));
const p = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(p[0]?.id ?? p[0]);
}
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'engagement-admin',
email: 'engagement-admin@example.com',
password_hash: await bcrypt.hash('EngagementAdmin123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'engagement-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('photos').where('event_id', eventId).update({ view_count: 0, download_count: 0 });
await db('access_logs').where('event_id', eventId).del();
});
describe('view_count via the view beacon (#895 — previously never written)', () => {
const beacon = (photoId, token = galleryToken()) => request(app)
.post(`/api/gallery/${SLUG}/photo/${photoId}/view`)
.set('Authorization', `Bearer ${token}`);
it('increments exactly the beaconed photo', async () => {
expect((await beacon(photoIds[0])).status).toBe(204);
expect((await getPhoto(photoIds[0])).view_count).toBe(1);
expect((await beacon(photoIds[0])).status).toBe(204);
expect((await getPhoto(photoIds[0])).view_count).toBe(2);
// Other photos untouched
expect((await getPhoto(photoIds[1])).view_count).toBe(0);
});
it('serving the image bytes does NOT count (preloads must not inflate)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photo/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
await settle();
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
});
it('rejects the slideshow kiosk (migration 138 design)', async () => {
const res = await beacon(photoIds[0], galleryToken({ accessLevel: 'slideshow' }));
expect(res.status).toBeGreaterThanOrEqual(400);
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
});
it("404s a photo that isn't in the event", async () => {
const res = await beacon(999999);
expect(res.status).toBe(404);
});
});
describe('download_count', () => {
it('single-photo download increments (regression pin)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
await settle();
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
expect((await getPhoto(photoIds[1])).download_count).toBe(0);
});
it('download-selected increments exactly the selected photos (#895)', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({ photo_ids: [photoIds[0], photoIds[1]] });
expect(res.status).toBe(200);
await settle();
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
expect((await getPhoto(photoIds[1])).download_count).toBe(1);
expect((await getPhoto(photoIds[2])).download_count).toBe(0);
});
it('download-all increments every downloadable photo (#895)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download-all`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
await settle();
for (const id of photoIds) {
expect((await getPhoto(id)).download_count).toBe(1);
}
});
it('skipped archive entries do not count (missing source file)', async () => {
// Own event so the on-the-fly archiver path is guaranteed — the
// main event may have a cached zip from the previous test's
// background generation, and racing its build/invalidate hangs.
// The route also fires a background pre-zip build after streaming;
// against this event's intentionally missing file it crashes with
// an async ENOENT that jest attributes to whatever test is running
// by then — neutralize it, it's not under test here.
const downloadZipService = require('../../src/services/downloadZipService');
const generateZipSpy = jest.spyOn(downloadZipService, 'generateZip')
.mockResolvedValue({ success: false, error: 'disabled in test' });
const slug2 = `${SLUG}-skip`;
const ev = await db('events').insert({
slug: slug2,
event_type: 'wedding',
event_name: 'Engagement Skip Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${slug2}/share`,
share_token: 'engagement-skip-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
const eventId2 = ev[0]?.id ?? ev[0];
const dir2 = path.join(process.env.STORAGE_PATH, 'events/active', slug2);
fs.mkdirSync(dir2, { recursive: true });
const ids2 = [];
for (let i = 0; i < 2; i++) {
// Only photo 0 gets a real file — photo 1's source is missing.
if (i === 0) fs.writeFileSync(path.join(dir2, `photo-${i}.jpg`), Buffer.from('skip-test-bytes'));
const p = await db('photos').insert({
event_id: eventId2,
filename: `photo-${i}.jpg`,
path: `${slug2}/photo-${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
ids2.push(p[0]?.id ?? p[0]);
}
const token2 = jwt.sign(
{ eventId: eventId2, eventSlug: slug2, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const res = await request(app)
.get(`/api/gallery/${slug2}/download-all`)
.set('Authorization', `Bearer ${token2}`);
expect(res.status).toBe(200);
await settle();
expect((await db('photos').where('id', ids2[0]).first()).download_count).toBe(1);
// photo-1's source was missing → skipped from the zip → not counted
expect((await db('photos').where('id', ids2[1]).first()).download_count).toBe(0);
generateZipSpy.mockRestore();
});
});
describe('admin photos list exposes the counters (#895 follow-up)', () => {
it('returns view_count and download_count so the Engagement column can render them', async () => {
// The list mapper builds an explicit object — before this fix it
// omitted both fields, so the admin table showed 0 forever even
// though the DB counted correctly.
await request(app)
.post(`/api/gallery/${SLUG}/photo/${photoIds[0]}/view`)
.set('Authorization', `Bearer ${galleryToken()}`);
await request(app)
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
await settle();
const res = await request(app)
.get(`/api/admin/photos/${eventId}/photos`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
const row = res.body.photos.find((p) => p.id === photoIds[0]);
expect(row.view_count).toBe(1);
expect(row.download_count).toBe(1);
const untouched = res.body.photos.find((p) => p.id === photoIds[1]);
expect(untouched.view_count).toBe(0);
expect(untouched.download_count).toBe(0);
});
});
describe('admin event-detail total_downloads (#895 — one definition everywhere)', () => {
it('counts singles and every zip variant, one row each', async () => {
const row = (action) => ({
event_id: eventId,
ip_address: '127.0.0.1',
user_agent: 'jest',
action,
});
await db('access_logs').insert([
row('download'),
row('download_all'),
row('download_all_presigned'),
row('download_selected'),
row('view'), // not a download
]);
const res = await request(app)
.get(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body.total_downloads).toBe(4);
});
});
});
@@ -0,0 +1,181 @@
/**
* Project ownership — GHSA-wrg5 (project routes) and GHSA-93x4 (project email
* endpoints).
*
* Project routes authorized on generic events.view / events.edit with NO
* ownership check, so an editor could enumerate, read, update and aggregate
* projects belonging to other admins' events. The email endpoints keyed on an
* email_queue id alone, so any id could be previewed/resent/cancelled.
*
* `projects` had no owner column. It was added in migration 167 (backfilled
* from linked events) rather than relying only on the transitive
* events.project_id -> events.created_by path, because a brand-new EMPTY
* project has no linked event to infer an owner from — which is exactly where
* the create -> attach flow begins.
*/
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-projown-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'projown-test-secret';
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('project ownership (GHSA-wrg5 / GHSA-93x4)', () => {
let db; let cleanup; let app;
let editorToken; let superToken; let editorId; let superId;
let ownProjectId; let foreignProjectId; let foreignEventId; let foreignEmailId;
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@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];
return {
id,
token: jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET, { expiresIn: '1h', issuer: 'picpeak-auth' },
),
};
};
const mkProject = async (name, createdBy) => {
const r = await db('projects').insert({
name, status: 'active', created_by: createdBy,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
await db('feature_flags').insert({ key: 'projects', value: 1 })
.onConflict('key').merge({ value: 1 });
const editor = await mkAdmin('proj-editor', 'editor');
const sup = await mkAdmin('proj-super', 'super_admin');
editorToken = editor.token; editorId = editor.id;
superToken = sup.token; superId = sup.id;
ownProjectId = await mkProject('own-project', editorId);
foreignProjectId = await mkProject('foreign-project', superId);
// A foreign event linked to the foreign project, plus a queued email on it.
const ev = await db('events').insert({
slug: 'foreign-ev',
event_type: 'wedding',
event_name: 'Foreign Event',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: 'ftok', share_link: '/gallery/foreign-ev/ftok',
created_by: superId,
project_id: foreignProjectId,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
foreignEventId = ev[0]?.id ?? ev[0];
const em = await db('email_queue').insert({
event_id: foreignEventId,
recipient_email: 'client@example.com',
email_type: 'gallery_created',
status: 'sent',
created_at: new Date().toISOString(),
}).returning('id');
foreignEmailId = em[0]?.id ?? em[0];
app = express();
app.use(express.json());
app.use('/api/admin/projects', require('../../src/routes/adminProjects'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('lists only the editor\'s own projects', async () => {
const res = await request(app)
.get('/api/admin/projects')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const names = (res.body.projects || res.body.data?.projects || []).map((p) => p.name);
expect(names).toContain('own-project');
expect(names).not.toContain('foreign-project');
});
it('refuses to read a foreign project', async () => {
const res = await request(app)
.get(`/api/admin/projects/${foreignProjectId}`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(res.status);
});
it('refuses to update or aggregate a foreign project', async () => {
const update = await request(app)
.put(`/api/admin/projects/${foreignProjectId}`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ name: 'hijacked' });
expect([403, 404]).toContain(update.status);
const overview = await request(app)
.get(`/api/admin/projects/${foreignProjectId}/overview`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(overview.status);
// And the name must not have changed.
const row = await db('projects').where({ id: foreignProjectId }).first();
expect(row.name).toBe('foreign-project');
});
it('refuses to attach a FOREIGN event to an owned project', async () => {
const res = await request(app)
.post(`/api/admin/projects/${ownProjectId}/events`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ eventId: foreignEventId });
expect([403, 404]).toContain(res.status);
const ev = await db('events').where({ id: foreignEventId }).first();
expect(ev.project_id).toBe(foreignProjectId); // still attached to its own
});
it('refuses to preview or act on a foreign queued email (GHSA-93x4)', async () => {
const preview = await request(app)
.get(`/api/admin/projects/email/${foreignEmailId}/preview`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(preview.status);
const cancel = await request(app)
.post(`/api/admin/projects/email/${foreignEmailId}/cancel`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(cancel.status);
});
it('leaves super_admin unrestricted', async () => {
const res = await request(app)
.get(`/api/admin/projects/${foreignProjectId}`)
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).toBe(200);
});
});
@@ -0,0 +1,115 @@
/**
* Project ownership edge cases (GHSA-wrg5, codex round 2).
*
* The first predicate union'd "any linked event I can see" with the stored
* owner, which opened two holes:
* - a project owned by B containing ONE legacy ownerless event became
* readable by everyone (and /overview aggregates B's other events,
* invoices and emails);
* - migration 167 deliberately leaves multi-owner projects NULL, and a NULL
* owner was treated as "everyone's".
* The stored owner is now authoritative, and a NULL owner only derives access
* when EVERY linked event is accessible.
*/
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-projedge-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'projedge-test-secret';
const bcrypt3 = require('bcrypt');
const { bootCrmDb: boot3, seedMinimal: seed3 } = require('../integration/helpers/crmDb');
describe('project ownership edge cases (GHSA-wrg5, round 2)', () => {
let db3; let cleanup3; let ownership; let editorA; let editorB;
const mkAdmin3 = async (username, roleName) => {
const role = await db3('roles').where({ name: roleName }).first();
const r = await db3('admin_users').insert({
username, email: `${username}@example.com`,
password_hash: await bcrypt3.hash('Passw0rd!', 4),
role_id: role.id, is_active: 1,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkProject3 = async (name, createdBy) => {
const r = await db3('projects').insert({
name, status: 'active', created_by: createdBy,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkEvent3 = async (slug, createdBy, projectId) => {
const r = await db3('events').insert({
slug, event_type: 'wedding', event_name: slug, event_date: '2026-08-01',
host_email: 'h@e.com', admin_email: 'a@e.com', password_hash: 'x',
share_token: `t-${slug}`, share_link: `/g/${slug}/t-${slug}`,
created_by: createdBy, project_id: projectId,
expires_at: new Date(Date.now() + 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db: db3, cleanup: cleanup3 } = await boot3());
await seed3(db3);
ownership = require('../../src/middleware/ownership');
editorA = await mkAdmin3('edge-a', 'editor');
editorB = await mkAdmin3('edge-b', 'editor');
}, 120000);
afterAll(async () => { if (cleanup3) await cleanup3(); });
it('one ownerless event in B\'s project does not expose it to A', async () => {
const pid = await mkProject3('b-project', editorB);
await mkEvent3('b-owned-ev', editorB, pid);
await mkEvent3('legacy-ev', null, pid); // ownerless legacy event
const idsA = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
expect(idsA).not.toContain(Number(pid));
const idsB = await ownership.ownedProjectIds({ id: editorB, roleName: 'editor' });
expect(idsB).toContain(Number(pid));
});
it('a mixed-owner project left NULL by migration 167 is not global', async () => {
const pid = await mkProject3('ambiguous', null);
await mkEvent3('mix-a-ev', editorA, pid);
await mkEvent3('mix-b-ev', editorB, pid);
for (const who of [editorA, editorB]) {
const ids = await ownership.ownedProjectIds({ id: who, roleName: 'editor' });
expect(ids).not.toContain(Number(pid));
}
});
it('a NULL-owner project whose events are all mine IS mine', async () => {
const pid = await mkProject3('legacy-mine', null);
await mkEvent3('mine-ev', editorA, pid);
const ids = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
expect(ids).toContain(Number(pid));
});
it('a project whose creator was deleted falls back to its events', async () => {
const ghost = await mkAdmin3('ghost-admin', 'editor');
const pid = await mkProject3('orphaned', ghost);
await mkEvent3('orphan-ev', editorA, pid);
await db3('admin_users').where({ id: ghost }).del();
const ids = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
expect(ids).toContain(Number(pid));
});
it('super_admin stays unrestricted', async () => {
expect(await ownership.ownedProjectIds({ id: 1, roleName: 'super_admin' })).toBeNull();
});
});
@@ -51,7 +51,7 @@ describe('publicContracts routes', () => {
contractId = inserted[0]?.id ?? inserted[0];
app = buildRouteApp('/api/public/contracts', require('../../src/routes/publicContracts'));
}, 60000);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -33,7 +33,7 @@ describe('publicPaymentCheck routes', () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
app = buildRouteApp('/api/public/payment-check', require('../../src/routes/publicPaymentCheck'));
}, 60000);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -60,7 +60,7 @@ describe('publicQuotes routes', () => {
quoteId = inserted[0]?.id ?? inserted[0];
app = buildRouteApp('/api/public/quotes', require('../../src/routes/publicQuotes'));
}, 60000);
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
@@ -0,0 +1,86 @@
/**
* Restore path containment must not break the normal restore wizard
* (GHSA-fw4c, codex round 2).
*
* `source` is usually a SOURCE TYPE, not a path: RestoreWizard posts
* 'local' | 's3' | 'upload', and restoreService.restore() branches on those
* literals before deriving a directory. The first version of the containment
* check treated `source` as a path, so path.resolve('local') landed outside
* the configured backup roots and BOTH /validate and /start returned 400 —
* blocking every normal restore.
*/
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-restorepath-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restorepath-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('restore path allowlist (GHSA-fw4c)', () => {
let db; let cleanup; let checkRestorePathsAllowed;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// Configure a backup root so the allowlist is actually active.
for (const [key, value] of [['backup_destination_path', '/backup']]) {
const existing = await db('app_settings').where({ setting_key: key }).first();
if (existing) {
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
} else {
await db('app_settings').insert({
setting_key: key, setting_value: JSON.stringify(value), setting_type: 'backup',
});
}
}
({ checkRestorePathsAllowed } = require('../../src/routes/adminRestore')._internal);
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('allows the wizard\'s source TYPE tokens', async () => {
for (const source of ['local', 's3', 'upload']) {
const err = await checkRestorePathsAllowed({
source, manifestPath: '/backup/manifests/backup-manifest-1.json',
});
expect(err).toBeNull();
}
});
it('allows an s3:// source URL', async () => {
const err = await checkRestorePathsAllowed({
source: 's3://bucket/key/backup.tar.gz',
manifestPath: '/backup/manifests/backup-manifest-1.json',
});
expect(err).toBeNull();
});
it('still rejects a manifestPath outside the configured roots', async () => {
const err = await checkRestorePathsAllowed({
source: 'local', manifestPath: '/etc/passwd',
});
expect(err).toMatch(/inside a configured backup location/i);
});
it('still rejects a traversal manifestPath', async () => {
const err = await checkRestorePathsAllowed({
source: 'local', manifestPath: '/backup/../etc/shadow',
});
expect(err).toBeTruthy();
});
it('accepts a real path source inside the roots', async () => {
const err = await checkRestorePathsAllowed({
source: '/backup/run-1', manifestPath: '/backup/run-1/manifest.json',
});
expect(err).toBeNull();
});
});
@@ -0,0 +1,136 @@
/**
* Secure-image view route token binding (GHSA-g94x-8vv8-3c9f).
*
* The view route GET /api/secure-images/:slug/secure/:photoId/:token serves
* via <img src> with the token in the URL, so it can't carry a gallery-token
* header like the download sibling. Before the fix it validated only the
* token signature and took the gallery/photo from the URL — so a token minted
* on any PUBLIC gallery read every other gallery's photos with no password.
*
* Pins that the route now enforces the scope inside the token:
* - the URL photoId must equal the token's minted photoId
* - the gallery embedded in the token's sessionId must equal the URL gallery
* A token minted on gallery A cannot read gallery B under either check; a
* token used on its own gallery+photo passes the binding.
*/
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-secimg-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'secimg-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-secimg-storage-'));
// Stub the anti-bot/rate-limit middleware so the fingerprint is deterministic
// — the token below is minted with the same fingerprint, so verifySecureToken
// passes and the binding logic under test is what decides the outcome.
jest.mock('../../src/middleware/secureImageMiddleware', () => ({
secureImageAccess: (req, _res, next) => {
req.clientInfo = { fingerprint: 'test-fp', ip: '127.0.0.1', userAgent: 'jest' };
next();
},
getSecurityStatus: (_req, res) => res.json({ ok: true }),
}));
const request = require('supertest');
const express = require('express');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const secureImageService = require('../../src/services/secureImageService');
describe('secure-image view route token binding (GHSA-g94x)', () => {
let db;
let cleanup;
let app;
let galleryA; let galleryB;
let photoA; let photoB;
const mkEvent = async (slug, requirePassword) => {
const r = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
require_password: requirePassword ? 1 : 0,
share_link: `/gallery/${slug}/share`,
share_token: `${slug}-share`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkPhoto = async (eventId, slug, filename) => {
const dir = path.join(process.env.STORAGE_PATH, 'events/active', slug);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), Buffer.from('img'));
const r = await db('photos').insert({
event_id: eventId,
filename,
path: `${slug}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
// Mint a token exactly as the mint route does — bound to (photoId, gallery
// sessionId, fingerprint) — bypassing the anti-bot HTTP path.
const mint = (photoId, eventId) => secureImageService.generateSecureToken(
photoId,
`gallery_public_${eventId}_${Date.now()}`,
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 },
);
const view = (slug, photoId, token) => request(app)
.get(`/api/secure-images/${slug}/secure/${photoId}/${token}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
galleryA = await mkEvent('secimg-public-a', false); // public — token source
galleryB = await mkEvent('secimg-private-b', true); // password-protected — victim
photoA = await mkPhoto(galleryA, 'secimg-public-a', 'a.jpg');
photoB = await mkPhoto(galleryB, 'secimg-private-b', 'b.jpg');
app = express();
app.use(express.json());
app.use('/api/secure-images', require('../../src/routes/secureImages'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('rejects a gallery-A token used against gallery B (cross-photo)', async () => {
const token = mint(photoA, galleryA);
const res = await view('secimg-private-b', photoB, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this photo/i);
});
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
const token = mint(photoA, galleryA);
// URL photoId matches the token, so the photo check passes — the gallery
// check (sessionId gallery A != URL gallery B) must catch it.
const res = await view('secimg-private-b', photoA, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this gallery/i);
});
it('lets a token read its own gallery + photo (binding passes)', async () => {
const token = mint(photoA, galleryA);
const res = await view('secimg-public-a', photoA, token);
// Binding passes; serving may 200/404/500 depending on the pipeline, but
// it must NOT be rejected as a token mismatch.
expect(res.status).not.toBe(403);
});
});
@@ -75,7 +75,7 @@ describe('admin Live Slideshow endpoints', () => {
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 30000);
}, 120000);
afterAll(async () => { await cleanup(); });
@@ -67,11 +67,10 @@ async function insertEvent(db, over = {}) {
describe('public Live Slideshow routes', () => {
let db; let cleanup; let app;
// bootCrmDb runs the full migration set against a fresh SQLite file, which
// takes <2s locally but has been observed to exceed Jest's default 5s
// `beforeAll` timeout on slower GitHub Actions runners (~5.4s — runner-to-
// runner I/O variance). Raise the hook timeout so this doesn't intermittently
// block PRs on CI; doesn't affect happy-path local runs.
// bootCrmDb runs the full migration set against a fresh SQLite file and the
// chain keeps growing via backports. Hook-argument timeouts OVERRIDE the
// 120s jest.config default (same trap as the jest.setTimeout pins) — keep
// this at 120000, matching the config.
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
@@ -86,7 +85,7 @@ describe('public Live Slideshow routes', () => {
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 30000);
}, 120000);
afterAll(async () => { await cleanup(); });
@@ -0,0 +1,155 @@
/**
* v1 API tokens must respect event ownership (GHSA-9697).
*
* migration 081 documents the intent — "the token's effective permissions are
* the intersection of the user's role permissions and the token's own scope
* flags" — but it was never implemented:
*
* - apiTokenAuth selected only id/username/email/role_id, so
* req.admin.roleName was undefined and every ownership helper (which all
* key on roleName) could not distinguish a super_admin from a viewer.
* - No v1 route applied requirePermission or a created_by predicate, so any
* valid token listed every event and — worst — GET /events/:id/share-link
* returned ANY event's share_token, which is the gallery access credential.
*
* Scenario pinned here: a token owned by a restricted (non-super_admin) admin
* must see only its owner's events, and must not obtain a foreign share_token.
*/
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-v1own-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'v1own-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1own-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { generateApiToken } = require('../../src/middleware/apiTokenAuth');
describe('v1 event ownership (GHSA-9697)', () => {
let db; let cleanup; let app;
let editorToken; let superToken;
let ownEventId; let foreignEventId;
const FOREIGN_SHARE_TOKEN = 'f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0';
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@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');
return r[0]?.id ?? r[0];
};
const mkToken = async (adminId, scopes = 'admin') => {
const { plaintext, hashed } = generateApiToken();
await db('api_tokens').insert({
name: `tok-${adminId}`,
hashed_token: hashed,
scopes,
created_by: adminId,
created_at: new Date().toISOString(),
});
return plaintext;
};
const mkEvent = async (slug, createdBy, shareToken) => {
const r = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: shareToken,
share_link: `/gallery/${slug}/${shareToken}`,
created_by: createdBy,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const editorId = await mkAdmin('restricted-editor', 'editor');
const superId = await mkAdmin('root-admin', 'super_admin');
editorToken = await mkToken(editorId);
superToken = await mkToken(superId);
ownEventId = await mkEvent('own-event', editorId, 'a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1');
foreignEventId = await mkEvent('foreign-event', superId, FOREIGN_SHARE_TOKEN);
app = express();
app.use(express.json());
app.use('/api/v1', require('../../src/routes/v1/events'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('lists only the token owner\'s events', async () => {
const res = await request(app)
.get('/api/v1/events')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const slugs = res.body.events.map((e) => e.slug);
expect(slugs).toContain('own-event');
expect(slugs).not.toContain('foreign-event');
});
it('refuses to read a foreign event', async () => {
const res = await request(app)
.get(`/api/v1/events/${foreignEventId}`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(res.status);
});
it('does NOT hand out a foreign event\'s share_token', async () => {
const res = await request(app)
.get(`/api/v1/events/${foreignEventId}/share-link`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(res.status);
expect(JSON.stringify(res.body)).not.toContain(FOREIGN_SHARE_TOKEN);
});
it('still allows the owner to read their own event and share link', async () => {
const detail = await request(app)
.get(`/api/v1/events/${ownEventId}`)
.set('Authorization', `Bearer ${editorToken}`);
expect(detail.status).toBe(200);
const share = await request(app)
.get(`/api/v1/events/${ownEventId}/share-link`)
.set('Authorization', `Bearer ${editorToken}`);
expect(share.status).toBe(200);
expect(share.body.share_token).toBe('a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1');
});
it('leaves super_admin tokens unrestricted', async () => {
const res = await request(app)
.get(`/api/v1/events/${foreignEventId}/share-link`)
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).toBe(200);
expect(res.body.share_token).toBe(FOREIGN_SHARE_TOKEN);
});
});
@@ -0,0 +1,108 @@
/**
* v1 token scopes must intersect the owner's CURRENT role permissions
* (GHSA-9697, codex round 2).
*
* Migration 081 documents effective permissions as the intersection of the
* owner's role permissions and the token's scope flags. requireApiScope only
* ever checked the scope half, so a token minted while its owner was
* super_admin kept full write access after the owner was demoted to viewer —
* userManagementService never touches api_tokens, so the token outlives the
* demotion. Ownership scoping alone does not close this: the demoted owner
* still *owns* their events.
*/
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-v1perm-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'v1perm-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1perm-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { generateApiToken } = require('../../src/middleware/apiTokenAuth');
describe('v1 token scopes intersect role permissions (GHSA-9697)', () => {
let db; let cleanup; let app; let viewerToken; let viewerEventId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const role = await db('roles').where({ name: 'viewer' }).first();
const r = await db('admin_users').insert({
username: 'demoted-owner',
email: 'demoted@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 ownerId = r[0]?.id ?? r[0];
// A token still carrying the broad 'admin' scope from before demotion.
const { plaintext, hashed } = generateApiToken();
await db('api_tokens').insert({
name: 'stale-token',
hashed_token: hashed,
scopes: 'admin',
created_by: ownerId,
created_at: new Date().toISOString(),
});
viewerToken = plaintext;
const ev = await db('events').insert({
slug: 'viewer-ev',
event_type: 'wedding',
event_name: 'Viewer Event',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: 'vtok',
share_link: '/gallery/viewer-ev/vtok',
created_by: ownerId,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
viewerEventId = ev[0]?.id ?? ev[0];
app = express();
app.use(express.json());
app.use('/api/v1', require('../../src/routes/v1/events'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('denies event creation to a demoted viewer despite an admin-scope token', async () => {
const res = await request(app)
.post('/api/v1/events')
.set('Authorization', `Bearer ${viewerToken}`)
.send({ event_name: 'Nope', event_type: 'wedding' });
expect(res.status).toBe(403);
});
it('denies photo upload to a demoted viewer on their OWN event', async () => {
const res = await request(app)
.post(`/api/v1/events/${viewerEventId}/photos`)
.set('Authorization', `Bearer ${viewerToken}`)
.attach('photo', Buffer.from('x'), 'a.jpg');
expect(res.status).toBe(403);
});
it('still allows the viewer to READ their own event', async () => {
const res = await request(app)
.get(`/api/v1/events/${viewerEventId}`)
.set('Authorization', `Bearer ${viewerToken}`);
expect(res.status).toBe(200);
});
});
@@ -22,7 +22,7 @@ const crypto = require('crypto');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
jest.setTimeout(30000);
jest.setTimeout(120000);
describe('backupIntegrityService.verifyDocumentArtefacts', () => {
let db;
@@ -0,0 +1,241 @@
/**
* Backup/restore hardening — GHSA-h652 (unbounded gunzip) and GHSA-hgp8
* (unkeyed manifest checksum).
*
* h652: decompressFile() piped gunzip straight to disk with no expanded-size
* bound, so a small crafted .gz could fill the volume.
*
* hgp8: the manifest checksum is a plain SHA-256 — it proves the manifest was
* not corrupted, not that it is authentic. BACKUP_MANIFEST_KEY upgrades new
* manifests to a keyed HMAC. It is deliberately OPT-IN and verify-if-present:
* the key cannot live in the database (the database is inside the backup), so
* a mandatory HMAC would lock an operator out of the exact disaster-recovery
* case this system exists for.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const zlib = require('zlib');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bkharden-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bkharden-test-secret';
const { restoreService } = require('../../src/services/restoreService');
const backupManifest = require('../../src/services/backupManifest');
describe('decompressFile expanded-size bound (GHSA-h652)', () => {
let dir;
beforeAll(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-gz-')); });
afterAll(() => { fs.rmSync(dir, { recursive: true, force: true }); });
afterEach(() => { delete process.env.RESTORE_MAX_DECOMPRESSED_BYTES; });
it('aborts when the decompressed stream exceeds the limit', async () => {
// 5 MB of zeroes compresses to a few KB — the classic shape of the attack.
const gzPath = path.join(dir, 'bomb.gz');
fs.writeFileSync(gzPath, zlib.gzipSync(Buffer.alloc(5 * 1024 * 1024, 0)));
process.env.RESTORE_MAX_DECOMPRESSED_BYTES = String(64 * 1024); // 64 KB
await expect(
restoreService.decompressFile(gzPath, path.join(dir, 'out-bomb'))
).rejects.toThrow(/exceeds limit/i);
});
it('still decompresses a normal file within the limit', async () => {
const payload = Buffer.from('SELECT 1;\n'.repeat(100));
const gzPath = path.join(dir, 'ok.gz');
fs.writeFileSync(gzPath, zlib.gzipSync(payload));
const outPath = path.join(dir, 'out-ok');
await restoreService.decompressFile(gzPath, outPath);
expect(fs.readFileSync(outPath)).toEqual(payload);
});
});
describe('manifest checksum keying (GHSA-hgp8)', () => {
// validateManifest requires all of these sections to be present.
const baseManifest = () => ({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
database: { type: 'sqlite' },
verification: { total_checksum: null, checksum_algorithm: null },
});
afterEach(() => { delete process.env.BACKUP_MANIFEST_KEY; });
it('produces a different digest when a key is set', () => {
const m = baseManifest();
const unkeyed = backupManifest.calculateManifestChecksum(m, { keyed: false });
const keyed = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
expect(keyed).not.toBe(unkeyed);
});
it('validates a legacy unkeyed manifest even when a key IS configured', () => {
// Disaster recovery: manifests written before keying must not become
// un-restorable the moment the operator sets a key.
const m = baseManifest();
m.verification.checksum_algorithm = 'sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
it('accepts a keyed manifest when the matching key is configured', () => {
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
const m = baseManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
it('rejects a keyed manifest whose body was tampered with', () => {
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
const m = baseManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
m.files.manifest[0].path = '../../etc/passwd';
expect(() => backupManifest.validateManifest(m)).toThrow(/checksum verification failed/i);
});
it('does NOT brick restore when a keyed manifest meets a missing key', () => {
// Key lost with the host — the precise moment a restore is needed.
const m = baseManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
delete process.env.BACKUP_MANIFEST_KEY;
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
});
describe('manifest checksum coverage (canonicalization)', () => {
const fullManifest = () => ({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
database: { type: 'sqlite' },
verification: { total_checksum: null, checksum_algorithm: 'sha256' },
});
afterEach(() => { delete process.env.BACKUP_MANIFEST_KEY; });
it('covers nested file entries (the old replacer dropped them)', () => {
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
// Tampering a file path must now change the digest.
m.files.manifest[0].path = '../../etc/passwd';
expect(() => backupManifest.validateManifest(m)).toThrow(/checksum verification failed/i);
});
it('still accepts a manifest written with the legacy serialization', () => {
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(
m, { keyed: false, legacy: true }
);
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
});
describe('checksum verification is shared and downgrade-aware (codex round 2)', () => {
const fullManifest = () => ({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
database: { type: 'sqlite' },
verification: { total_checksum: null, checksum_algorithm: 'sha256' },
});
afterEach(() => {
delete process.env.BACKUP_MANIFEST_KEY;
delete process.env.BACKUP_MANIFEST_REQUIRE_KEYED;
});
it('accepts a legacy-serialized manifest through the SHARED verifier', () => {
// restoreService recomputed the digest itself with the canonical
// serializer, which rejected every pre-existing backup.
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(
m, { keyed: false, legacy: true },
);
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(true);
expect(res.warnings.join(' ')).toMatch(/legacy checksum serialization/i);
});
it('warns but accepts an unkeyed manifest when a key is configured', () => {
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(true);
expect(res.warnings.join(' ')).toMatch(/authenticity NOT established/i);
});
it('REJECTS the algorithm downgrade once REQUIRE_KEYED is on', () => {
// Attacker rewrites the manifest, strips checksum_algorithm and recomputes
// a plain SHA-256. With the strict flag set that must not verify.
const m = fullManifest();
m.files.manifest[0].path = '../../etc/passwd';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(false);
expect(res.error).toMatch(/downgrade/i);
});
it('rejects a keyed manifest with no key when REQUIRE_KEYED is on', () => {
const m = fullManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'k' });
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
expect(backupManifest.verifyManifestChecksum(m).valid).toBe(false);
});
it('REJECTS a manifest whose checksum was stripped entirely', () => {
// The cheapest bypass of every rule above: delete the field instead of
// forging it. Both the helper's early return and restoreService's
// `if (…total_checksum)` guard used to wave that through.
const m = fullManifest();
delete m.verification.total_checksum;
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(false);
expect(res.error).toMatch(/no checksum/i);
delete m.verification;
expect(backupManifest.verifyManifestChecksum(m).valid).toBe(false);
});
it('REJECTS an unkeyed manifest under REQUIRE_KEYED even with no key configured', () => {
// Strict mode is a claim about the manifests, not about this host — so a
// fresh disaster-recovery box that lost BACKUP_MANIFEST_KEY must not
// silently start accepting plain SHA-256 manifests again.
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
delete process.env.BACKUP_MANIFEST_KEY;
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(false);
expect(res.error).toMatch(/downgrade/i);
});
});
@@ -0,0 +1,159 @@
/**
* Regression test: business documents must be written under STORAGE_PATH.
*
* quoteService.persistDocPdf, the invoice sending/reminder writers and the
* contract signature writers all built their target from
* `path.join(process.cwd(), 'storage', 'business-docs', ...)`. Both compose
* files pin STORAGE_PATH=/app/storage and the image's WORKDIR is /app, so the
* two expressions name the same directory and the bug was invisible on a stock
* deployment. Point STORAGE_PATH anywhere else — a NAS mount, a second disk,
* the single-container image's /data volume — and quotes, invoices, Mahnungen
* and contract PDFs were written outside the configured storage root, so they
* were missed by backups and lost when the container was replaced.
*
* Rather than assert on internals, this drives the module boundary the fix
* changed: getStoragePath() is the one resolver, so a temporary STORAGE_PATH
* must be where the bytes land.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
describe('business documents honour STORAGE_PATH', () => {
let tmpRoot;
let originalStoragePath;
beforeEach(() => {
originalStoragePath = process.env.STORAGE_PATH;
tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-storage-'));
process.env.STORAGE_PATH = tmpRoot;
jest.resetModules();
});
afterEach(() => {
if (originalStoragePath === undefined) delete process.env.STORAGE_PATH;
else process.env.STORAGE_PATH = originalStoragePath;
fs.rmSync(tmpRoot, { recursive: true, force: true });
});
it('getStoragePath is the resolver the writers share', () => {
const { getStoragePath } = require('../../src/config/storage');
expect(getStoragePath()).toBe(tmpRoot);
});
it('no business-document writer still targets process.cwd()/storage', () => {
// Whitespace is collapsed before matching on purpose. The first version of
// this test compared against the single-line literal and therefore missed
// persistSignatureImage(), whose identical path.join was simply spread over
// seven lines — it reported green while signature PNGs still wrote outside
// STORAGE_PATH. Formatting must not decide whether a bug is visible.
const writers = [
'src/services/quoteService.js',
'src/services/invoice/sending.js',
'src/services/invoice/reminders.js',
'src/services/contract/signatureAssets.js',
'src/routes/adminDev.js',
];
const offenders = writers.filter((rel) => {
const source = fs.readFileSync(path.join(__dirname, '../../', rel), 'utf8');
return /process\.cwd\(\),'storage'/.test(source.replace(/\s+/g, ''));
});
expect(offenders).toEqual([]);
});
it('generated contract PDFs pass the containment check that serves them', () => {
// assertContractPdfPath guards the admin and public contract download
// routes. It listed only <cwd>/storage/business-docs/contract, so once the
// writers moved to STORAGE_PATH every freshly generated contract was
// refused with PATH_OUTSIDE_STORAGE — a worse failure than the bug being
// fixed. Both roots must be accepted.
const { assertContractPdfPath } = require('../../src/utils/safePath');
const { getStoragePath } = require('../../src/config/storage');
// assertPathInside realpaths both the file and each root, so the guard only
// means anything against a filesystem that actually has them — write them.
const write = (...segments) => {
const p = path.join(getStoragePath(), 'business-docs', 'contract', ...segments);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, 'bytes');
return p;
};
const generated = write('2026', 'C-2026-0001.pdf');
expect(() => assertContractPdfPath(generated)).not.toThrow();
// Signature PNGs live under the same root and are served by the same guard.
const signature = write('signatures', '7', 'customer-1.png');
expect(() => assertContractPdfPath(signature)).not.toThrow();
// And the guard still refuses a real file outside every allowed root.
const foreign = path.join(tmpRoot, 'outside.pdf');
fs.writeFileSync(foreign, 'bytes');
expect(() => assertContractPdfPath(foreign)).toThrow(/outside the storage roots/i);
});
it('the guard takes its root from the shared resolver, not its own fallback', () => {
// The regression this pins: the guard used to compute
// `STORAGE_PATH || <cwd>/storage` itself. That agrees with getStoragePath()
// only while STORAGE_PATH is set — unset, the shared resolver falls back
// module-relative to <repo>/storage while the guard fell back to
// <cwd>/storage, and the backend is normally started from backend/. Writers
// and guard then disagreed and contract downloads 403'd.
//
// Mocking the resolver is what makes this provable AND safe. If the guard
// consumes getStoragePath(), the mock moves its root; if it rolled its own
// expression, the mock would have no effect and the assertion fails. It
// also keeps every path inside the tmpdir — an earlier version of this test
// deleted `<resolved root>/business-docs` in cleanup, which with
// STORAGE_PATH unset resolves to a developer's real, gitignored
// <repo>/storage and would have destroyed local documents on `npm test`.
jest.resetModules();
jest.doMock('../../src/config/storage', () => ({ getStoragePath: () => tmpRoot }));
const { assertContractPdfPath } = require('../../src/utils/safePath');
const root = path.join(tmpRoot, 'business-docs', 'contract', '2026');
fs.mkdirSync(root, { recursive: true });
const generated = path.join(root, 'C-2026-0002.pdf');
fs.writeFileSync(generated, 'bytes');
expect(() => assertContractPdfPath(generated)).not.toThrow();
jest.dontMock('../../src/config/storage');
});
it('writes land under STORAGE_PATH, not the working directory', () => {
const { getStoragePath } = require('../../src/config/storage');
// Mirror what persistDocPdf does: derive the root, create it, write.
const root = path.join(getStoragePath(), 'business-docs', 'quote', '2026');
fs.mkdirSync(root, { recursive: true });
const filePath = path.join(root, 'Q-2026-0001.pdf');
fs.writeFileSync(filePath, 'pdf-bytes');
expect(fs.existsSync(filePath)).toBe(true);
expect(filePath.startsWith(tmpRoot)).toBe(true);
// And crucially NOT beside the process working directory.
expect(filePath.startsWith(path.join(process.cwd(), 'storage'))).toBe(false);
});
it('the PDF font lookup consults the storage root before the legacy path', () => {
// A custom font under STORAGE_PATH/fonts used to be unreachable, so the
// document silently rendered with the built-in face instead.
const fontDir = path.join(tmpRoot, 'fonts');
fs.mkdirSync(fontDir, { recursive: true });
const fontPath = path.join(fontDir, 'Brand.ttf');
fs.writeFileSync(fontPath, 'ttf');
const { getStoragePath } = require('../../src/config/storage');
const raw = 'Brand.ttf';
const candidates = [
path.join(getStoragePath(), raw.replace(/^\/+/, '')),
path.join(getStoragePath(), 'fonts', path.basename(raw)),
path.join(process.cwd(), 'storage', 'fonts', path.basename(raw)),
];
const found = candidates.find((p) => fs.existsSync(p));
expect(found).toBe(fontPath);
});
});
@@ -0,0 +1,127 @@
/**
* Inbound-mail resource caps (GHSA-2qf9).
*
* emailIntakeService downloaded, parsed and persisted every message with no
* size, attachment-count or attachment-byte limit. Anyone who can email the
* operator's mailbox reaches this path unauthenticated.
*
* The teeth were in the dedup key: on failure the service wrote an error row
* keyed `err-<uid>-<Date.now()>`, which can never match the envelope-derived
* `messageId` the dedup pass compares against. So the same oversized message
* was re-downloaded every poll interval forever — and an OOM-kill/restart just
* resumed the loop. This pins that an over-limit message is (a) never
* downloaded and (b) recorded under its REAL message id so it dedups.
*/
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-intake-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'intake-test-secret';
process.env.EMAIL_INTAKE_MAX_MESSAGE_BYTES = '1000';
const OVERSIZED_UID = 11;
const NORMAL_UID = 12;
const OVERSIZED_MSGID = '<huge@example.com>';
const fetchOneCalls = [];
jest.mock('imapflow', () => ({
ImapFlow: class {
async connect() {}
async logout() {}
async getMailboxLock() { return { release() {} }; }
async search() { return [OVERSIZED_UID, NORMAL_UID]; }
// Envelope pass now also returns `size`.
async *fetch() {
yield { uid: OVERSIZED_UID, size: 50_000, envelope: { messageId: OVERSIZED_MSGID } };
yield { uid: NORMAL_UID, size: 500, envelope: { messageId: '<ok@example.com>' } };
}
async fetchOne(uid) {
fetchOneCalls.push(String(uid));
return { source: Buffer.from('Subject: ok\r\n\r\nbody') };
}
async messageFlagsAdd() { return true; }
},
}));
jest.mock('mailparser', () => ({
simpleParser: async () => ({
messageId: '<ok@example.com>',
subject: 'ok',
date: new Date(),
attachments: [],
text: 'body',
html: null,
}),
}));
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('email intake caps (GHSA-2qf9)', () => {
let db; let cleanup; let intake;
let pollResult;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// pollOnce short-circuits unless the feature flag is on AND an IMAP
// account is configured — without both, this suite would pass vacuously.
await db('feature_flags')
.insert({ key: 'incomingMail', value: 1 })
.onConflict('key').merge({ value: 1 });
// getImapConfig() reads email_configs.first() — seedMinimal may already
// have inserted a row, so update that one rather than adding a second
// (the first row would win and report "unconfigured").
const imapFields = {
imap_host: 'imap.example.com',
imap_user: 'intake@example.com',
imap_pass: 'x',
imap_folder: 'INBOX',
};
const existingCfg = await db('email_configs').first();
if (existingCfg) {
await db('email_configs').where({ id: existingCfg.id }).update(imapFields);
} else {
await db('email_configs').insert({
smtp_host: 'smtp.example.com',
smtp_port: 587,
from_email: 'intake@example.com',
...imapFields,
});
}
intake = require('../../src/services/emailIntakeService');
pollResult = await intake.pollOnce().catch((e) => ({ thrown: e.message }));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('actually ran the poll (guards against a vacuous suite)', () => {
expect(pollResult).toBeDefined();
expect(pollResult.skipped).toBeUndefined();
});
it('never downloads a message whose envelope size exceeds the cap', () => {
// The oversized uid must never reach fetchOne (the source download) —
// that download is the DoS. The normal one must still be processed.
expect(fetchOneCalls).not.toContain(String(OVERSIZED_UID));
expect(fetchOneCalls).toContain(String(NORMAL_UID));
});
it('records the skip under the REAL message id so it dedups next poll', async () => {
const row = await db('received_emails').where({ message_id: OVERSIZED_MSGID }).first();
expect(row).toBeTruthy();
expect(row.status).toBe('error');
expect(String(row.error)).toMatch(/too large/i);
// The whole point: keyed by messageId, NOT err-<uid>-<timestamp>, which
// could never match the dedup pass and so looped forever.
expect(row.message_id).not.toMatch(/^err-/);
});
});
@@ -0,0 +1,116 @@
/**
* ensureHeroImage must work for external/reference photos (#1166 follow-up).
*
* resolvePhotoStorageKey returns null for external photos by design, and that
* null used to be handed straight to withLocalCopy, which throws — so the hero
* route caught it and redirected to the full ORIGINAL. #1078 fixed exactly
* this shape for ensurePreviewImage and nobody carried it across.
*
* It only became visible when the Story hero started asking for hero_url
* instead of photo.url: on a managed gallery that is a real saving, on a
* reference-mode gallery it quietly changed nothing.
*/
const path = require('path');
const fs = require('fs').promises;
const os = require('os');
const sharp = require('sharp');
const EXTERNAL_ROOT = path.join(os.tmpdir(), `picpeak-hero-ext-${process.pid}`);
process.env.EXTERNAL_MEDIA_ROOT = EXTERNAL_ROOT;
jest.mock('../../src/database/db', () => {
const state = { event: null, updates: [] };
const api = (table) => {
if (table === 'events') return { where: () => ({ first: async () => state.event }) };
if (table === 'photos') {
return { where: (criteria) => ({ update: async (values) => { state.updates.push({ criteria, values }); return 1; } }) };
}
throw new Error(`unexpected table in test: ${table}`);
};
api.__state = state;
return { db: api };
});
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
const storageModule = require('../../src/services/storage');
const { db } = require('../../src/database/db');
const EVENT = { id: 7, slug: 'nas-wedding', source_mode: 'reference', external_path: 'weddings/2026-08' };
async function writeSourceJpeg(absPath, { width = 2400, height = 1600 } = {}) {
await fs.mkdir(path.dirname(absPath), { recursive: true });
const buf = Buffer.alloc(width * height * 3);
for (let i = 0; i < buf.length; i++) buf[i] = (i * 7) % 256;
await sharp(buf, { raw: { width, height, channels: 3 } }).jpeg({ quality: 90 }).toFile(absPath);
}
describe('ensureHeroImage — external sources', () => {
let storage; let storageRoot; let imageProcessor;
beforeAll(async () => {
storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-hero-store-'));
storage = new LocalFsStorage({ root: storageRoot });
await storage.init();
storageModule.setStorageForTesting(storage);
delete require.cache[require.resolve('../../src/services/imageProcessor')];
imageProcessor = require('../../src/services/imageProcessor');
await fs.mkdir(path.join(EXTERNAL_ROOT, EVENT.external_path), { recursive: true });
}, 30000);
afterAll(async () => {
storageModule.resetStorage();
await fs.rm(storageRoot, { recursive: true, force: true }).catch(() => {});
await fs.rm(EXTERNAL_ROOT, { recursive: true, force: true }).catch(() => {});
});
beforeEach(() => { db.__state.event = EVENT; db.__state.updates = []; });
it.each(['external', 'reference'])('generates a hero for a %s photo off the mount', async (sourceOrigin) => {
const name = `${sourceOrigin}-hero.jpg`;
await writeSourceJpeg(path.join(EXTERNAL_ROOT, EVENT.external_path, name));
const photo = {
id: sourceOrigin === 'external' ? 301 : 302,
event_id: EVENT.id,
source_origin: sourceOrigin,
// Root-relative, as stored since #1163: external_relpath is resolved
// from EXTERNAL_MEDIA_ROOT, not from event.external_path. The base-
// relative form this fixture used to carry stopped resolving the moment
// that landed, and ensureHeroImage returned null.
external_relpath: path.join(EVENT.external_path, name),
filename: name,
hero_path: null,
};
const key = await imageProcessor.ensureHeroImage(photo);
// The regression: this returned null and the route redirected to the
// full original.
expect(key).toBeTruthy();
expect(await storage.exists(key)).toBe(true);
// Per-photo basename, so two events sharing a NAS filename cannot clobber
// each other — same rule as the preview tier.
expect(key).toContain(`ext${photo.id}_`);
expect(db.__state.updates).toEqual([{ criteria: { id: photo.id }, values: { hero_path: key } }]);
});
it('returns null rather than throwing when the external source is gone', async () => {
const photo = {
id: 303, event_id: EVENT.id, source_origin: 'external',
external_relpath: path.join(EVENT.external_path, 'not-on-the-mount.jpg'), filename: 'not-on-the-mount.jpg', hero_path: null,
};
await expect(imageProcessor.ensureHeroImage(photo)).resolves.toBeNull();
expect(db.__state.updates).toEqual([]);
});
it('returns null for a reference-mode row with no source_origin', async () => {
// Mode falls back to the event's, so resolvePhotoStorageKey yields null.
// That used to reach withLocalCopy and throw out of the function.
const photo = {
id: 304, event_id: EVENT.id, source_origin: null, external_relpath: null,
filename: 'orphan.jpg', path: 'nas-wedding/individual/orphan.jpg', hero_path: null,
};
await expect(imageProcessor.ensureHeroImage(photo)).resolves.toBeNull();
});
});
@@ -0,0 +1,219 @@
/**
* Regression tests for #1078 — ensurePreviewImage must generate previews for
* external/reference photos, not silently fall back to the full-size original.
*
* resolvePhotoStorageKey returns null for external photos by design, and that
* null used to be handed straight to withLocalCopy, which throws. The lightbox
* preview route caught the throw and redirected to the original, so a gallery
* whose photos all live on an external mount paid full size on every open —
* the exact cost the preview tier (#492) exists to avoid.
*/
const path = require('path');
const fs = require('fs').promises;
const os = require('os');
const sharp = require('sharp');
// Must be set before externalMediaService is first required: it caches the
// resolved root on first call, and the dir has to exist to win over the
// container default.
const EXTERNAL_ROOT = path.join(os.tmpdir(), `picpeak-ext-media-${process.pid}`);
process.env.EXTERNAL_MEDIA_ROOT = EXTERNAL_ROOT;
jest.mock('../../src/database/db', () => {
const state = { event: null, updates: [] };
const api = (table) => {
if (table === 'events') {
return { where: () => ({ first: async () => state.event }) };
}
if (table === 'photos') {
return {
where: (criteria) => ({
update: async (values) => {
state.updates.push({ criteria, values });
return 1;
},
}),
};
}
throw new Error(`unexpected table in test: ${table}`);
};
api.__state = state;
return { db: api };
});
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
const storageModule = require('../../src/services/storage');
const { db } = require('../../src/database/db');
const EVENT = {
id: 7,
slug: 'nas-wedding',
source_mode: 'reference',
external_path: 'weddings/2026-08-smith',
};
async function writeSourceJpeg(absPath, { width = 2400, height = 1600 } = {}) {
await fs.mkdir(path.dirname(absPath), { recursive: true });
const buf = Buffer.alloc(width * height * 3);
for (let i = 0; i < buf.length; i++) buf[i] = (i * 7) % 256;
await sharp(buf, { raw: { width, height, channels: 3 } }).jpeg({ quality: 90 }).toFile(absPath);
}
describe('ensurePreviewImage — external/reference sources (#1078)', () => {
let storage;
let storageRoot;
let imageProcessor;
beforeAll(async () => {
storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-preview-store-'));
storage = new LocalFsStorage({ root: storageRoot });
await storage.init();
storageModule.setStorageForTesting(storage);
// Require AFTER the storage injection so the module sees it.
delete require.cache[require.resolve('../../src/services/imageProcessor')];
imageProcessor = require('../../src/services/imageProcessor');
await fs.mkdir(path.join(EXTERNAL_ROOT, EVENT.external_path), { recursive: true });
}, 30000);
afterAll(async () => {
storageModule.resetStorage();
await fs.rm(storageRoot, { recursive: true, force: true }).catch(() => {});
await fs.rm(EXTERNAL_ROOT, { recursive: true, force: true }).catch(() => {});
});
beforeEach(() => {
db.__state.event = EVENT;
db.__state.updates = [];
});
it.each(['external', 'reference'])(
'generates a downscaled preview for a %s photo off the media mount',
async (sourceOrigin) => {
const name = `${sourceOrigin}-shot.jpg`;
await writeSourceJpeg(path.join(EXTERNAL_ROOT, EVENT.external_path, name));
const photo = {
id: sourceOrigin === 'external' ? 101 : 102,
event_id: EVENT.id,
source_origin: sourceOrigin,
// Relative to the media ROOT, not to event.external_path (#1163).
external_relpath: path.join(EVENT.external_path, name),
filename: name,
preview_path: null,
};
const key = await imageProcessor.ensurePreviewImage(photo);
// Per-photo basename so two events referencing the same NAS filename
// can't clobber each other's preview.
expect(key).toBe(`previews/preview_ext${photo.id}_${name}`);
expect(await storage.exists(key)).toBe(true);
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
expect(meta.format).toBe('jpeg');
// 2400x1600 capped at the 1920 long edge, aspect preserved.
expect(meta.width).toBe(1920);
expect(meta.height).toBe(1280);
// The generated key is persisted so the next open short-circuits.
expect(db.__state.updates).toEqual([
{ criteria: { id: photo.id }, values: { preview_path: key } },
]);
}
);
it('short-circuits on an existing valid preview instead of regenerating', async () => {
const name = 'already-previewed.jpg';
await writeSourceJpeg(path.join(EXTERNAL_ROOT, EVENT.external_path, name));
const photo = {
id: 103,
event_id: EVENT.id,
source_origin: 'external',
external_relpath: path.join(EVENT.external_path, name),
filename: name,
preview_path: null,
};
const first = await imageProcessor.ensurePreviewImage(photo);
db.__state.updates = [];
const second = await imageProcessor.ensurePreviewImage({ ...photo, preview_path: first });
expect(second).toBe(first);
expect(db.__state.updates).toEqual([]);
});
it('returns null (never throws) when the external source is missing', async () => {
const photo = {
id: 104,
event_id: EVENT.id,
source_origin: 'external',
external_relpath: 'not-on-the-mount.jpg',
filename: 'not-on-the-mount.jpg',
preview_path: null,
};
await expect(imageProcessor.ensurePreviewImage(photo)).resolves.toBeNull();
expect(db.__state.updates).toEqual([]);
});
it('returns null (never throws) for a row with no source_origin in a reference event', async () => {
// Mode falls back to event.source_mode = 'reference', so
// resolvePhotoStorageKey yields null. That used to reach withLocalCopy and
// throw out of ensurePreviewImage instead of honouring null-on-failure.
const photo = {
id: 105,
event_id: EVENT.id,
source_origin: null,
external_relpath: null,
filename: 'orphan.jpg',
path: 'nas-wedding/individual/orphan.jpg',
preview_path: null,
};
await expect(imageProcessor.ensurePreviewImage(photo)).resolves.toBeNull();
expect(db.__state.updates).toEqual([]);
});
it('branches on source_origin, so a row selected without it looks managed', async () => {
// Pins why the /regenerate-previews caller must select source_origin:
// an external row missing that column takes the managed path, where
// resolvePhotoStorageKey yields null and generation is skipped.
const name = 'column-starved.jpg';
await writeSourceJpeg(path.join(EXTERNAL_ROOT, EVENT.external_path, name));
const starved = {
id: 106,
event_id: EVENT.id,
external_relpath: path.join(EVENT.external_path, name),
preview_path: null,
};
await expect(imageProcessor.ensurePreviewImage(starved)).resolves.toBeNull();
await expect(
imageProcessor.ensurePreviewImage({ ...starved, source_origin: 'external', filename: name })
).resolves.toBe(`previews/preview_ext106_${name}`);
});
it('still routes managed photos through the storage backend', async () => {
const sourceKey = 'events/active/managed-event/individual/managed.jpg';
const localSource = path.join(os.tmpdir(), `picpeak-managed-${process.pid}.jpg`);
await writeSourceJpeg(localSource, { width: 800, height: 600 });
await storage.put(sourceKey, await fs.readFile(localSource), { contentType: 'image/jpeg' });
await fs.rm(localSource, { force: true });
db.__state.event = { id: 8, slug: 'managed-event', source_mode: 'managed' };
const photo = {
id: 201,
event_id: 8,
source_origin: 'managed',
path: 'managed-event/individual/managed.jpg',
filename: 'managed.jpg',
preview_path: null,
};
const key = await imageProcessor.ensurePreviewImage(photo);
expect(key).toBe('previews/preview_managed.jpg');
expect(await storage.exists(key)).toBe(true);
});
});
@@ -0,0 +1,92 @@
/**
* A pre-#1162 backup must still restore (#1162 review).
*
* `replaceAllTables` suspends FOREIGN KEY enforcement for the load — Postgres
* via `session_replication_role = replica`, SQLite via `defer_foreign_keys` —
* but neither of those suspends a UNIQUE index. An archive taken before
* migration 186 carries exactly the duplicate photo rows that migration
* removes, so the batchInsert would hit the new index and roll the entire
* restore back, after every table had already been emptied.
*
* These pin the drop → load → dedupe → recreate sequence the restore now
* performs, and the failure it exists to prevent.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const {
dedupeExternalPhotos,
createExternalRelpathIndex,
dropExternalRelpathIndex,
} = require('../../src/services/externalPhotoDedupe');
describe('restoring an archive that predates the unique index (#1162)', () => {
let knex; let tmpDir;
// What a pre-186 archive's photos.ndjson holds for a racing import: the same
// file twice, sub-millisecond apart.
const ARCHIVE_ROWS = [
{ id: 1, event_id: 1, external_relpath: 'Trip/a.jpg', source_origin: 'external' },
{ id: 2, event_id: 1, external_relpath: 'Trip/a.jpg', source_origin: 'external' },
{ id: 3, event_id: 1, external_relpath: 'Trip/b.jpg', source_origin: 'external' },
];
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-restore-dedupe-'));
knex = require('knex')({
client: 'sqlite3',
connection: { filename: path.join(tmpDir, 'db.sqlite') },
useNullAsDefault: true,
});
});
afterAll(async () => {
if (knex) await knex.destroy();
await fs.promises.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
});
beforeEach(async () => {
await knex.schema.dropTableIfExists('photos');
await knex.schema.createTable('photos', (t) => {
t.integer('id').primary();
t.integer('event_id');
t.string('external_relpath');
t.string('thumbnail_path');
t.string('source_origin').defaultTo('managed');
});
await createExternalRelpathIndex(knex);
});
it('would abort the whole restore without the drop', async () => {
// The regression, stated directly: this is what the target instance does
// today when handed a legacy archive.
await expect(knex.batchInsert('photos', ARCHIVE_ROWS, 100)).rejects.toThrow(/unique/i);
});
it('loads, dedupes and comes back constrained', async () => {
await dropExternalRelpathIndex(knex);
await knex.batchInsert('photos', ARCHIVE_ROWS, 100);
const removed = await dedupeExternalPhotos(knex);
await createExternalRelpathIndex(knex);
expect(removed).toBe(1);
expect((await knex('photos').orderBy('id')).map((r) => r.external_relpath))
.toEqual(['Trip/a.jpg', 'Trip/b.jpg']);
// The target must not be left unprotected by the restore that dropped it.
await expect(
knex('photos').insert({ id: 9, event_id: 1, external_relpath: 'Trip/b.jpg', source_origin: 'external' })
).rejects.toThrow(/unique/i);
});
it('is a no-op for an archive that has no duplicates', async () => {
await dropExternalRelpathIndex(knex);
await knex.batchInsert('photos', ARCHIVE_ROWS.slice(1), 100);
expect(await dedupeExternalPhotos(knex)).toBe(0);
await expect(createExternalRelpathIndex(knex)).resolves.toBeUndefined();
expect(await knex('photos').count('* as c').first()).toEqual({ c: 2 });
});
});
@@ -0,0 +1,134 @@
/**
* The preview tier must not destroy what it is previewing.
*
* generatePreviewImage encoded JPEG unconditionally. JPEG has no alpha channel
* and no second frame, so a transparent PNG came back flattened onto a solid
* background and an animated GIF came back as its first frame — for every
* consumer of this tier, not just the lightbox: the slideshow (#1015), admin
* previews, and the face avatars that read it as a whole-frame rendition.
*
* Driven against real Sharp output, because the whole question is what is in
* the encoded bytes.
*/
const path = require('path');
const fs = require('fs').promises;
const os = require('os');
const sharp = require('sharp');
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
const storageModule = require('../../src/services/storage');
/** A 2x2 GIF89a with two frames and a NETSCAPE loop block. */
const ANIMATED_GIF = Buffer.from([
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
0x02, 0x00, 0x02, 0x00,
0xF0, 0x00, 0x00,
0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF,
0x21, 0xFF, 0x0B, 0x4E, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45,
0x32, 0x2E, 0x30, 0x03, 0x01, 0x00, 0x00, 0x00,
0x21, 0xF9, 0x04, 0x00, 0x0A, 0x00, 0x00, 0x00,
0x2C, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00,
0x02, 0x02, 0x44, 0x01, 0x00,
0x21, 0xF9, 0x04, 0x00, 0x0A, 0x00, 0x00, 0x00,
0x2C, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00,
0x02, 0x02, 0x4C, 0x01, 0x00,
0x3B,
]);
// No width-tier case here: the responsive `?w=` renditions (#1095) are
// main-only, so this branch has a single canonical preview per photo.
describe('generatePreviewImage encodes for the source (#1166 follow-up)', () => {
let storage; let storageRoot; let srcDir; let imageProcessor;
beforeAll(async () => {
storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-prevfmt-store-'));
srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-prevfmt-src-'));
storage = new LocalFsStorage({ root: storageRoot });
await storage.init();
storageModule.setStorageForTesting(storage);
delete require.cache[require.resolve('../../src/services/imageProcessor')];
imageProcessor = require('../../src/services/imageProcessor');
}, 30000);
afterAll(async () => {
storageModule.resetStorage();
await fs.rm(storageRoot, { recursive: true, force: true }).catch(() => {});
await fs.rm(srcDir, { recursive: true, force: true }).catch(() => {});
});
const outMeta = async (key) => sharp(storage.resolveLocalPath(key)).metadata();
it('keeps transparency, as WebP, for a PNG with alpha', async () => {
const src = path.join(srcDir, 'logo.png');
await sharp({
create: { width: 800, height: 600, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
}).png().toFile(src);
const key = await imageProcessor.generatePreviewImage(src, { regenerate: true });
expect(key).toBe('previews/preview_logo.webp');
const meta = await outMeta(key);
expect(meta.format).toBe('webp');
// The regression, stated directly: JPEG would have flattened this.
expect(meta.hasAlpha).toBe(true);
});
it('keeps every frame, as WebP, for an animated GIF', async () => {
const src = path.join(srcDir, 'wave.gif');
// Hand-assembled rather than produced by Sharp: writing a multi-page image
// needs pageHeight threaded through the pipeline, and a fixture that
// silently comes out single-page would make this test pass for the wrong
// reason. 2x2, two frames, black then white.
await fs.writeFile(src, ANIMATED_GIF);
// Precondition: the fixture really is animated.
expect((await sharp(src, { animated: true }).metadata()).pages).toBe(2);
const key = await imageProcessor.generatePreviewImage(src, { regenerate: true });
expect(key).toBe('previews/preview_wave.webp');
const meta = await sharp(storage.resolveLocalPath(key), { animated: true }).metadata();
expect(meta.format).toBe('webp');
// The regression, stated directly: JPEG kept only the first frame.
expect(meta.pages).toBe(2);
});
it('still writes plain JPEG for an ordinary photo', async () => {
// The common path must not pay for the two cases above: JPEG is smaller
// than WebP at the quality this tier uses, and every existing preview is
// one.
const src = path.join(srcDir, 'shot.jpg');
await sharp({ create: { width: 2400, height: 1600, channels: 3, background: { r: 90, g: 90, b: 90 } } })
.jpeg().toFile(src);
const key = await imageProcessor.generatePreviewImage(src, { regenerate: true });
expect(key).toBe('previews/preview_shot.jpg');
const meta = await outMeta(key);
expect(meta.format).toBe('jpeg');
// 2400x1600 capped at the 1920 long edge, aspect preserved — unchanged.
expect([meta.width, meta.height]).toEqual([1920, 1280]);
});
it('names the output for what it wrote, not for the source', async () => {
// A PNG source used to produce `preview_x.png` holding JPEG bytes. Harmless
// while the route hard-coded image/jpeg; wrong once the encoding varies,
// and the route now reads the extension.
const src = path.join(srcDir, 'opaque.png');
await sharp({ create: { width: 400, height: 400, channels: 3, background: { r: 1, g: 2, b: 3 } } })
.png().toFile(src);
const key = await imageProcessor.generatePreviewImage(src, { regenerate: true });
expect(key).toBe('previews/preview_opaque.jpg');
expect((await outMeta(key)).format).toBe('jpeg');
});
it('returns null on an unreadable source instead of throwing', async () => {
const src = path.join(srcDir, 'not-an-image.jpg');
await fs.writeFile(src, 'plain text');
await expect(imageProcessor.generatePreviewImage(src, { regenerate: true })).resolves.toBeNull();
});
});

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