Compare commits

..

21 Commits

Author SHA1 Message Date
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
61 changed files with 3458 additions and 334 deletions
+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
+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:
+1 -1
View File
@@ -1 +1 @@
{".":"3.45.16"}
{".":"3.46.4"}
+46
View File
@@ -5,6 +5,52 @@ 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.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)
@@ -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);
});
});
@@ -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([]);
});
});
});
@@ -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);
});
});
});
@@ -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);
});
});
@@ -0,0 +1,259 @@
/**
* 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; event
// external_path is relative to it, exactly as on a real install.
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: '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: '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: '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: '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);
});
@@ -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,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,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,218 @@
/**
* 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 relpath = `${sourceOrigin}-shot.jpg`;
await writeSourceJpeg(path.join(EXTERNAL_ROOT, EVENT.external_path, relpath));
const photo = {
id: sourceOrigin === 'external' ? 101 : 102,
event_id: EVENT.id,
source_origin: sourceOrigin,
external_relpath: relpath,
filename: relpath,
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}_${relpath}`);
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 relpath = 'already-previewed.jpg';
await writeSourceJpeg(path.join(EXTERNAL_ROOT, EVENT.external_path, relpath));
const photo = {
id: 103,
event_id: EVENT.id,
source_origin: 'external',
external_relpath: relpath,
filename: relpath,
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 relpath = 'column-starved.jpg';
await writeSourceJpeg(path.join(EXTERNAL_ROOT, EVENT.external_path, relpath));
const starved = {
id: 106,
event_id: EVENT.id,
external_relpath: relpath,
preview_path: null,
};
await expect(imageProcessor.ensurePreviewImage(starved)).resolves.toBeNull();
await expect(
imageProcessor.ensurePreviewImage({ ...starved, source_origin: 'external', filename: relpath })
).resolves.toBe(`previews/preview_ext106_${relpath}`);
});
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,90 @@
/**
* Regeneration must not destroy a good thumbnail when the source is
* unreadable (#1129).
*
* The old code deleted the target BEFORE sharp opened the source, so a NAS
* mount that blipped mid-run left the previous rendition gone and the database
* still pointing at it. Across a bulk regenerate that is the whole gallery,
* and it is precisely the "worse than before you pressed it" outcome #1129 is
* about.
*/
const path = require('path');
const fs = require('fs').promises;
const os = require('os');
const sharp = require('sharp');
jest.mock('../../src/database/db', () => ({
db: () => ({ where: () => ({ first: async () => null, update: async () => 1 }) }),
}));
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
const storageModule = require('../../src/services/storage');
describe('generateThumbnail — regenerate is non-destructive (#1129)', () => {
let storage; let root; let imageProcessor; let srcDir;
beforeAll(async () => {
root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-store-'));
srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-regen-src-'));
storage = new LocalFsStorage({ root });
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(root, { recursive: true, force: true }).catch(() => {});
await fs.rm(srcDir, { recursive: true, force: true }).catch(() => {});
});
async function writeSource(name, size = 400) {
const p = path.join(srcDir, name);
await sharp({ create: { width: size, height: size, channels: 3, background: { r: 1, g: 2, b: 3 } } })
.jpeg().toFile(p);
return p;
}
it('keeps the existing thumbnail when the source cannot be read', async () => {
const src = await writeSource('present.jpg');
const key = await imageProcessor.generateThumbnail(src, { regenerate: true });
expect(key).toBeTruthy();
expect(await storage.exists(key)).toBe(true);
const before = await storage.get(key).then((s) => new Promise((res) => {
const c = []; s.on('data', (d) => c.push(d)); s.on('end', () => res(Buffer.concat(c)));
}));
// The mount goes away between runs.
await fs.unlink(src);
const second = await imageProcessor.generateThumbnail(src, { regenerate: true })
.catch(() => null);
expect(second).toBeFalsy();
// The old rendition is still there and still serves. Previously it had
// been deleted before sharp ever looked at the source.
expect(await storage.exists(key)).toBe(true);
const after = await storage.get(key).then((s) => new Promise((res) => {
const c = []; s.on('data', (d) => c.push(d)); s.on('end', () => res(Buffer.concat(c)));
}));
expect(after.equals(before)).toBe(true);
});
it('still replaces the thumbnail when the source IS readable', async () => {
const src = await writeSource('replaceme.jpg', 400);
const key = await imageProcessor.generateThumbnail(src, { regenerate: true });
const firstSize = (await storage.stat(key)).size;
// Same key, different source content — the atomic rename in put() is what
// makes the pre-delete unnecessary.
await fs.rm(src);
await sharp({ create: { width: 400, height: 400, channels: 3, background: { r: 250, g: 40, b: 9 } } })
.jpeg().toFile(src);
const again = await imageProcessor.generateThumbnail(src, { regenerate: true });
expect(again).toBe(key);
expect((await storage.stat(key)).size).not.toBe(firstSize);
});
});
@@ -0,0 +1,143 @@
/**
* Regression test for #1024: quote/invoice PDF endpoints 500'd (or silently
* corrupted the filename) for customers whose name carries non-ASCII.
*
* The six PDF routes built the header by interpolating buildPdfFilename()'s
* result straight into `inline; filename="${filename}"`. HTTP header values
* are latin1, which splits the failure in two — and the split matters,
* because the issue reported the umlaut case as the 500 and it isn't:
*
* U+0080-U+00FF (ä ö ü ß — every German umlaut)
* No throw. The byte goes out raw and the client reads back a mangled
* name. A silent corruption, not an error.
*
* above U+00FF (Polish ł, Czech ř, Turkish ş, €, Cyrillic, CJK, emoji)
* Node's setHeader rejects it with ERR_INVALID_CHAR. Because the
* throw lands after the PDF buffer is already rendered, the whole
* request fails as an unhandled 500.
*
* buildContentDisposition() fixes both: an ASCII fallback for the legacy
* `filename=` parameter plus the RFC 5987 `filename*=UTF-8''…` form that
* carries the real name.
*
* These assertions run against the real Node header validator via a live
* express server, so they'd fail against the old interpolation rather than
* merely testing the helper in isolation.
*/
const express = require('express');
const request = require('supertest');
const { buildPdfFilename, sanitiseSegment } = require('../../src/utils/pdfFilename');
const { buildContentDisposition } = require('../../src/utils/filenameSanitizer');
// The RFC 5987 parameter prefix, i.e. filename*=UTF-8'' — the two trailing
// quotes are the (empty) language tag the spec puts between the charset and
// the percent-encoded value.
const RFC5987_PREFIX = 'filename*=UTF-8\'\'';
// Mirrors what the six PDF routes now do.
function buildApp(customer, docNumber = 'Q-2026-0042') {
const app = express();
app.get('/pdf', (req, res) => {
const filename = buildPdfFilename({ docNumber, customer, fallback: 'quote-preview' });
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
res.send(Buffer.from('%PDF-1.4 fake'));
});
// Mirrors the real error handler: an ERR_INVALID_CHAR throw inside the
// handler surfaces as a 500, which is what #1024 reported.
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => res.status(500).json({ error: err.code || err.message }));
return app;
}
describe('#1024 — PDF Content-Disposition with non-ASCII customer names', () => {
it('serves a PDF for a German umlaut name and keeps the name intact', async () => {
const res = await request(buildApp({ company_name: 'Müller Fotografie' })).get('/pdf');
expect(res.status).toBe(200);
const cd = res.headers['content-disposition'];
// RFC 5987 form carries the real, unmangled name...
expect(cd).toContain(RFC5987_PREFIX);
expect(cd).toContain(encodeURIComponent('Müller-Fotografie.pdf'));
// ...and the ASCII fallback is legal latin1 with no raw umlaut byte.
const fallback = /filename="([^"]+)"/.exec(cd)[1];
expect(fallback).toMatch(/^[\x20-\x7e]+$/);
});
it.each([
['Polish', 'Michał Kowalski'],
['Czech', 'Dvořák Studio'],
['Turkish', 'Şahin Fotoğraf'],
['Cyrillic', 'Иванов Фото'],
['CJK', '山田写真'],
['emoji', 'Studio 🎉 Berlin'],
])('does not 500 for a %s customer name (was ERR_INVALID_CHAR)', async (_label, company) => {
const res = await request(buildApp({ company_name: company })).get('/pdf');
expect(res.status).toBe(200);
const cd = res.headers['content-disposition'];
expect(cd).toContain(RFC5987_PREFIX);
// The legacy filename= token drops non-ASCII, so a name written entirely
// in another script degrades to just the document number
// (`Q-2026-0042_.pdf`). That's the intended trade — filename* carries the
// real name — but the fallback must still be a legal, non-empty,
// ASCII-only token, since that is what a client without RFC 5987 support
// ends up saving.
const fallback = /filename="([^"]*)"/.exec(cd)[1];
expect(fallback.length).toBeGreaterThan(0);
expect(fallback).toMatch(/^[\x20-\x7e]+$/);
expect(fallback).toContain('Q-2026-0042');
});
it('leaves a plain ASCII name on the familiar filename= form', async () => {
const res = await request(buildApp({ company_name: 'Bright Studio' })).get('/pdf');
expect(res.status).toBe(200);
expect(res.headers['content-disposition'])
.toContain('filename="Q-2026-0042_Bright-Studio.pdf"');
});
it('still works when the customer row is missing entirely (preview path)', async () => {
const res = await request(buildApp(null, null)).get('/pdf');
expect(res.status).toBe(200);
expect(res.headers['content-disposition']).toContain('quote-preview_customer.pdf');
});
// sanitiseSegment caps each segment at 80 UTF-16 code units. A cap landing
// inside an astral character used to leave a dangling high surrogate, which
// makes encodeURIComponent throw URIError inside buildContentDisposition —
// a 500 on the very endpoint this PR fixes, reached a different way.
it.each([
['emoji on the 80-char boundary', `${'a'.repeat(79)}🎉`],
['astral CJK on the boundary', `${'a'.repeat(79)}𠜎`],
['a label that is entirely astral', '🎉'.repeat(60)],
])('does not 500 when truncation splits a surrogate pair — %s', async (_label, company) => {
const res = await request(buildApp({ company_name: company })).get('/pdf');
expect(res.status).toBe(200);
expect(res.headers['content-disposition']).toContain(RFC5987_PREFIX);
});
it('drops the orphaned surrogate rather than widening the length cap', () => {
const seg = sanitiseSegment(`${'a'.repeat(79)}🎉`);
// 79 'a's + a half-emoji would be 80; the orphan is dropped, not kept.
expect(seg).toHaveLength(79);
expect(seg).toBe('a'.repeat(79));
// Nothing in the result may be an unpaired surrogate.
expect(seg).toBe(seg.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])/g, ''));
});
it('the raw interpolation these routes used to do really does throw', () => {
// Pins the root cause itself, so nobody "simplifies" the helper away.
const filename = buildPdfFilename({
docNumber: 'Q-2026-0042',
customer: { company_name: 'Michał Kowalski' },
});
const res = new (require('http').ServerResponse)({});
expect(() => res.setHeader('Content-Disposition', `inline; filename="${filename}"`))
.toThrow(/ERR_INVALID_CHAR|Invalid character/);
});
});
@@ -0,0 +1,160 @@
/**
* The contract that matters here is negative: a source that disappears must
* NOT be able to end the process (#1128).
*
* `fs.createReadStream` is lazy, so its ENOENT lands on a later tick, outside
* the route's try/catch. An EventEmitter emitting 'error' with no listener
* throws, and an uncaught throw from an I/O callback exits Node — which is how
* one missing thumbnail tier took every gallery on the install down.
*
* These use a REAL fs stream over a real missing path rather than a fake
* emitter: the point under test is the lazy-open timing, and a hand-rolled
* mock that emits synchronously would pass while proving nothing.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const { Readable } = require('stream');
const { EventEmitter } = require('events');
const { pipeStreamToResponse } = require('../../src/utils/streamResponse');
jest.mock('../../src/utils/logger', () => ({
warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn(),
}));
/** Minimal Express-ish response that records what happened to it. */
function makeRes() {
const res = new EventEmitter();
res.headers = { 'Content-Length': '1234', ETag: '"x"' };
res.statusCode = 200;
res.headersSent = false;
res.writableEnded = false;
res.body = null;
res.destroyed = false;
res.removeHeader = (h) => { delete res.headers[h]; };
res.setHeader = (h, v) => { res.headers[h] = v; };
res.status = (code) => { res.statusCode = code; return res; };
res.json = (payload) => { res.body = payload; res.writableEnded = true; return res; };
res.destroy = () => { res.destroyed = true; };
// pipe() target surface
res.write = () => true;
res.end = () => { res.writableEnded = true; };
res.on = EventEmitter.prototype.on.bind(res);
res.emit = EventEmitter.prototype.emit.bind(res);
return res;
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 50));
describe('pipeStreamToResponse (#1128)', () => {
it('turns a missing file into a 404 instead of an unhandled error', async () => {
const missing = path.join(os.tmpdir(), `picpeak-not-here-${Date.now()}.jpg`);
const res = makeRes();
pipeStreamToResponse(stream_(missing), res, { context: 'thumbnail for photo 1' });
await settle();
expect(res.statusCode).toBe(404);
expect(res.body).toEqual({ error: 'File not found' });
});
// How this test discriminates, since the failure mode is a process-level
// one: replacing the call above with a bare `stream.pipe(res)` — what the
// thumbnail route did — makes jest fail this suite on the unhandled 'error'
// event before either assertion runs. Verified by doing exactly that.
// Catching the throw with a process.on('uncaughtException') listener does
// NOT work here and would be theatre: the runner installs its own handling,
// so such a listener never sees it and the assertion could never fail.
function stream_(p) { return fs.createReadStream(p); }
it('strips every header that described the file it can no longer send', async () => {
const res = makeRes();
// What the image and zip routes actually stage before streaming.
res.headers = {
'Content-Length': '1234',
ETag: '"x"',
'Content-Type': 'image/jpeg',
'Content-Disposition': 'attachment; filename="gallery.zip"',
'Cache-Control': 'private, max-age=1800',
};
const stream = fs.createReadStream(path.join(os.tmpdir(), `gone-${Date.now()}.jpg`));
pipeStreamToResponse(stream, res);
await settle();
expect(res.headers['Content-Length']).toBeUndefined();
expect(res.headers.ETag).toBeUndefined();
// Express does NOT overwrite an existing Content-Type, so leaving it makes
// res.json() emit JSON labelled image/jpeg — or a corrupt .zip download.
expect(res.headers['Content-Type']).toBeUndefined();
expect(res.headers['Content-Disposition']).toBeUndefined();
});
it('does not let a transient 404 be cached as a broken tile', async () => {
const res = makeRes();
// The thumbnail route stages 30 minutes; the hero route an hour.
res.headers = { 'Cache-Control': 'private, max-age=1800' };
const stream = fs.createReadStream(path.join(os.tmpdir(), `gone3-${Date.now()}.jpg`));
pipeStreamToResponse(stream, res);
await settle();
// The regeneration race is transient by definition: the tier exists moments
// later. Caching this 404 would keep the tile broken long after the file is
// back — the opposite of what this helper is for.
expect(res.headers['Cache-Control']).toBe('no-store');
});
it('honours a caller that wants a different missing-status', async () => {
const res = makeRes();
const stream = fs.createReadStream(path.join(os.tmpdir(), `gone2-${Date.now()}.zip`));
pipeStreamToResponse(stream, res, { missingStatus: 410 });
await settle();
expect(res.statusCode).toBe(410);
});
it('destroys the response instead of rewriting a status that is already sent', async () => {
const res = makeRes();
res.headersSent = true;
const stream = new Readable({ read() {} });
pipeStreamToResponse(stream, res, { context: 'photo 9' });
stream.emit('error', Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
await settle();
// Once bytes are on the wire a 404 is not available; a truncated image the
// client would cache is worse than a broken connection.
expect(res.destroyed).toBe(true);
expect(res.statusCode).toBe(200);
expect(res.body).toBeNull();
});
it('reports a non-ENOENT failure as a 500 rather than a 404', async () => {
const res = makeRes();
const stream = new Readable({ read() {} });
pipeStreamToResponse(stream, res);
stream.emit('error', Object.assign(new Error('disk exploded'), { code: 'EIO' }));
await settle();
expect(res.statusCode).toBe(500);
expect(res.body).toEqual({ error: 'Failed to serve file' });
});
it('releases the source when the client hangs up mid-download', async () => {
const res = makeRes();
let destroyed = false;
const stream = new Readable({ read() {}, destroy(err, cb) { destroyed = true; cb(err); } });
pipeStreamToResponse(stream, res);
res.emit('close');
await settle();
// Otherwise an abandoned grid leaks one open fd per tile.
expect(destroyed).toBe(true);
});
});
@@ -77,7 +77,11 @@ const DEFAULT_CSS_TEMPLATE = `/*
.photo-card img {
width: 100%;
height: 200px;
/* 100%, not a fixed pixel height: every aspect-ratio layout (masonry,
justified, mosaic, gallery-premium) gives .photo-card a definite height
computed from photos.width/height, and this rule's specificity (0,1,1)
beats the .h-full utility (0,1,0) the layouts rely on — #1131. */
height: 100%;
object-fit: cover;
transition: transform 0.3s ease;
}
@@ -503,7 +503,9 @@ const LIQUID_GLASS_DARK = `/*
.photo-card img {
width: 100%;
height: 240px;
/* See #1131: a fixed height here beats the layouts' .h-full utility and
detaches the image from its aspect-ratio-sized card. */
height: 100%;
object-fit: cover;
transition: transform 0.4s ease, filter 0.4s ease;
filter: brightness(0.9);
@@ -639,7 +641,7 @@ const LIQUID_GLASS_DARK = `/*
}
.photo-card img {
height: 180px;
height: 100%;
}
/* Reduce animation complexity on mobile */
@@ -0,0 +1,123 @@
/**
* The bundled CSS templates pinned every gallery image to a fixed pixel
* height, which broke every aspect-ratio layout (#1131).
*
* Six of the seven layouts size a tile by putting a computed pixel height on
* `.photo-card` and letting the image fill it with `h-full`. A template rule
* of `.photo-card img { height: 200px }` has specificity (0,1,1) and beats
* `.h-full` at (0,1,0), so the image detached from its card: masonry rendered
* correctly-shaped cards with a 200px image glued to the top and empty
* background below — or, where the computed card was shorter than 200px, an
* image taller than its own container.
*
* "Elegant Dark" is seeded `is_default = true`, so this was the out-of-the-box
* result for anyone choosing any layout other than grid/timeline (where a
* fixed square happens to look deliberate).
*
* Migrations 052 and 053 are corrected for fresh installs; this repairs the
* rows already seeded. Templates are referenced by `events.css_template_id`
* and read at serve time rather than copied onto the event, so fixing the row
* fixes every gallery using it.
*
* SCOPE: every `.photo-card img` rule that carries a fixed PIXEL height, in
* every template — not just the two we seeded, and not just their pristine
* copies.
*
* That is broader than it first looks, and deliberately so. It is also not the
* scope this started with: matching the exact seeded text missed every install
* where the template had ever been saved through the editor, because
* `sanitizeCSS` strips newlines. Those are the majority, and a migration that
* silently no-ops on them while being recorded as applied is worse than none.
*
* The cost is that a fixed pixel height a user wrote themselves is rewritten
* too. That is judged acceptable because there is no layout it can be right
* for: all seven give `.photo-card` a definite height and expect the image to
* fill it, so a pixel height on the image can only detach it from its card.
* Anything that is not a fixed px height — %, vh, auto — is left alone, as is
* every declaration outside a `.photo-card img` body.
*/
/**
* Every `.photo-card img { … }` rule body, however it is spaced.
*
* Matching the exact seeded text does NOT work, and the reason is worth
* stating: `sanitizeCSS` strips all control characters (cssSanitizer.js:61),
* so the moment an admin saves a template through the editor — even only to
* rename it or toggle it — every newline is REMOVED from the stored CSS. The
* shipped `.photo-card img {\n height: 200px;` becomes
* `.photo-card img { height: 200px;`. An exact-match migration would find
* nothing on those installs, be recorded as applied, and leave the galleries
* broken with no second chance.
*
* Scoped to the rule body rather than the whole stylesheet, so the other pixel
* heights in these same templates — a 1px gradient divider, an 8px scrollbar —
* are untouched.
*/
/*
* Two details in this pattern are deliberate:
*
* * the selector part is a LIST, so `.photo-card img, .thumbnail img { … }`
* is recognised. Requiring `{` straight after `img` skipped grouped
* selectors entirely — and the migration would still be recorded as
* applied, so the template kept the bug with no second chance.
*
* * the body excludes braces, so a rule containing a NESTED block is not
* matched at all. `.photo-card img { & + .caption { height: 200px } }` is
* valid, passes the validator, and a `[^}]*` body would have captured the
* nested block and rewritten the caption's height instead. Skipping it
* means such a template keeps a fixed image height; corrupting unrelated
* declarations in a migration that cannot be undone is the worse of the
* two, and nesting does not appear in anything we ship.
*/
const PHOTO_CARD_IMG_RULE = /([^{}]*\.photo-card\s+img[^{}]*)\{([^{}]*)\}/g;
/**
* Only a fixed PIXEL height is wrong here; %, vh, auto and the rest stay.
*
* The lookbehind is load-bearing rather than defensive: without it the pattern
* matches the TAIL of `line-height`, `max-height`, `min-height` and any custom
* property ending in `-height`, and silently rewrites those instead — in a
* migration whose down() is deliberately irreversible.
*/
const FIXED_PX_HEIGHT = /(?<![\w-])height\s*:\s*\d+(?:\.\d+)?px/gi;
function relaxFixedImageHeights(css) {
return css.replace(PHOTO_CARD_IMG_RULE, (whole, selectors, body) => {
// .test() on a /g regex advances lastIndex, so it is reset on both sides
// of the check — leaving it set makes the NEXT rule start matching from an
// arbitrary offset and silently skip declarations.
FIXED_PX_HEIGHT.lastIndex = 0;
if (!FIXED_PX_HEIGHT.test(body)) return whole;
FIXED_PX_HEIGHT.lastIndex = 0;
return `${selectors}{${body.replace(FIXED_PX_HEIGHT, 'height: 100%')}}`;
});
}
exports.up = async function up(knex) {
if (!(await knex.schema.hasTable('css_templates'))) return;
const rows = await knex('css_templates').select('id', 'css_content');
let fixed = 0;
for (const row of rows) {
const original = row.css_content;
if (!original || typeof original !== 'string') continue;
const updated = relaxFixedImageHeights(original);
if (updated !== original) {
await knex('css_templates').where({ id: row.id }).update({ css_content: updated });
fixed += 1;
}
}
if (fixed > 0) {
console.log(` 175: relaxed the fixed image height in ${fixed} CSS template(s)`);
}
};
exports.down = async function down() {
// Deliberately irreversible. Putting the pixel heights back would re-break
// every aspect-ratio layout, and the rows may have been edited since — there
// is no version of "restore" here that is safer than doing nothing.
};
+15 -5
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "3.45.14",
"version": "3.46.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "3.45.14",
"version": "3.46.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -5315,9 +5315,19 @@
}
},
"node_modules/deepmerge-ts": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz",
"integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==",
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz",
"integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==",
"funding": [
{
"type": "ko-fi",
"url": "https://ko-fi.com/rebeccastevens"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/deepmerge-ts"
}
],
"license": "BSD-3-Clause",
"engines": {
"node": ">=16.0.0"
+3 -2
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.45.16",
"version": "3.46.4",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"engines": {
@@ -93,6 +93,7 @@
"@tootallnate/once": ">=3.0.1",
"ip-address": ">=10.3.1",
"uuid": "^11.1.1",
"nodemailer": "^9.0.1"
"nodemailer": "^9.0.1",
"deepmerge-ts": ">=8.0.1"
}
}
@@ -201,9 +201,9 @@ async function phaseImport(archivePath) {
const { importFromPicpeak } = require('../src/services/picpeakImportService');
// No currentAdminId: this is a CLI, there is no operator session to preserve.
// The SQLite install's own admin accounts come across with everything else.
// allowEngineSwitch: moving between engines is the whole point here. The
// upload/restore UI keeps refusing it.
const summary = await importFromPicpeak({ picpeakPath: archivePath, allowEngineSwitch: true });
// sqlite → pg is allowed by validateManifest's direction policy (#1041) —
// the same gate the upload/restore UI uses, no separate opt-in flag.
const summary = await importFromPicpeak({ picpeakPath: archivePath });
return JSON.stringify(summary || {});
}
+135 -129
View File
@@ -1,141 +1,147 @@
#!/usr/bin/env node
/**
* Script to regenerate missing thumbnails for photos in the database
* Usage: node scripts/regenerate-thumbnails.js [eventId]
* Fill in missing thumbnails for photos already in the database.
*
* The CLI fallback for when the admin UI is not reachable. It is deliberately
* "missing only": ensureThumbnail short-circuits on a thumbnail that is
* already present and valid, so re-running this is cheap and safe. To REBUILD
* everything after a settings change, use POST /api/admin/thumbnails/regenerate
* — that path drops the existing renditions first, which this one must not do.
*
* Resolution goes through ensureThumbnail rather than a hand-built path
* (#1148, same defect as #1129). This script used to compute
* `storage/events/active/<photo.path>` and fs.access it, a location that does
* not exist for `external` or `reference` rows — their originals live under
* the mount in events.external_path. Every such photo failed the check and was
* counted as an error, so on an external-media install the script was inert
* while reporting one error per photo.
*
* ensureThumbnail already branches on source_origin, resolves both kinds via
* photoResolver, uses the per-photo `ext<id>_` output name so two events
* referencing one NAS basename cannot clobber each other, and writes
* thumbnail_path back itself. Sharing it is what stops the script and the
* route drifting apart again.
*
* Usage:
* node scripts/regenerate-thumbnails.js [eventId]
*/
const path = require('path');
const fs = require('fs').promises;
const sharp = require('sharp');
const { db } = require('../src/database/db');
// Configuration
const THUMBNAIL_SIZE = 300;
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
async function ensureDirectoryExists(dirPath) {
try {
await fs.access(dirPath);
} catch {
await fs.mkdir(dirPath, { recursive: true });
console.log(`Created directory: ${dirPath}`);
}
}
async function generateThumbnail(photoPath, thumbnailPath) {
try {
await sharp(photoPath)
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
fit: 'cover',
position: 'center'
})
.jpeg({ quality: 80 })
.toFile(thumbnailPath);
return true;
} catch (error) {
console.error(`Failed to generate thumbnail for ${photoPath}:`, error.message);
return false;
}
}
const { ensureThumbnail, isThumbnailValid } = require('../src/services/imageProcessor');
async function regenerateThumbnails(eventId = null) {
try {
console.log('Starting thumbnail regeneration...');
console.log(`Storage path: ${STORAGE_PATH}`);
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}`);
// Ensure thumbnails directory exists
await ensureDirectoryExists(THUMBNAILS_DIR);
// Build query
let query = db('photos')
.join('events', 'photos.event_id', 'events.id')
.select(
'photos.id',
'photos.filename',
'photos.path',
'photos.thumbnail_path',
'events.slug as event_slug'
);
if (eventId) {
query = query.where('photos.event_id', eventId);
console.log(`Filtering for event ID: ${eventId}`);
}
const photos = await query;
console.log(`Found ${photos.length} photos to process`);
let successCount = 0;
let skipCount = 0;
let errorCount = 0;
for (const photo of photos) {
const photoPath = path.join(STORAGE_PATH, 'events/active', photo.path);
const thumbnailFilename = `thumb_${photo.filename}`;
const thumbnailPath = path.join(THUMBNAILS_DIR, thumbnailFilename);
try {
// Check if photo file exists
await fs.access(photoPath);
// Check if thumbnail already exists
try {
await fs.access(thumbnailPath);
console.log(`Thumbnail already exists for ${photo.filename}, skipping...`);
skipCount++;
continue;
} catch {
// Thumbnail doesn't exist, generate it
}
console.log(`Generating thumbnail for ${photo.filename}...`);
const success = await generateThumbnail(photoPath, thumbnailPath);
if (success) {
// Update database with thumbnail path
await db('photos')
.where('id', photo.id)
.update({
thumbnail_path: `thumbnails/${thumbnailFilename}`
});
successCount++;
console.log(`✓ Generated thumbnail for ${photo.filename}`);
} else {
errorCount++;
}
} catch (error) {
console.error(`✗ Photo file not found: ${photoPath}`);
errorCount++;
}
}
console.log('\nThumbnail regeneration complete!');
console.log(`- Successfully generated: ${successCount}`);
console.log(`- Skipped (already exist): ${skipCount}`);
console.log(`- Errors: ${errorCount}`);
console.log(`- Total processed: ${photos.length}`);
} catch (error) {
console.error('Error during thumbnail regeneration:', error);
process.exit(1);
} finally {
await db.destroy();
console.log('Starting thumbnail regeneration...');
// These columns are what ensureThumbnail branches on to resolve a source and
// name its output. Selecting a subset that misses
// source_origin/external_relpath is how the old path bug would come back —
// an external row would look managed and resolve under events/active.
let query = db('photos').select(
'id', 'event_id', 'path', 'filename', 'thumbnail_path',
'type', 'media_type', 'mime_type', 'source_origin', 'external_relpath'
);
if (eventId) {
query = query.where('event_id', eventId);
console.log(`Filtering for event ID: ${eventId}`);
}
// Skip videos. A video's thumbnail is a poster frame produced by
// videoProcessor, not a resize of the stored file, so handing the container
// to Sharp here only ever produced one error per row.
//
// Tested on every marker a video row can carry, not media_type alone:
// fileWatcher.processNewPhoto writes `type` and `mime_type` but never
// media_type, which defaults to 'image' — so an auto-imported video passes a
// media_type-only filter. Each clause is null-safe on its own so a row that
// simply has no mime_type is not swept up with them.
query = query
.where(function () {
this.whereNull('media_type').orWhere('media_type', '!=', 'video');
})
.where(function () {
this.whereNull('type').orWhere('type', '!=', 'video');
})
.where(function () {
this.whereNull('mime_type').orWhereNot('mime_type', 'like', 'video/%');
});
const photos = await query;
console.log(`Found ${photos.length} photos to process`);
let successCount = 0;
let skipCount = 0;
let errorCount = 0;
for (const photo of photos) {
const label = photo.filename || `photo ${photo.id}`;
try {
const existing = photo.thumbnail_path;
// Asked BEFORE the call, not inferred from the returned path afterwards.
// On local and external storage the key is deterministic, so repairing a
// missing or corrupt thumbnail hands back the identical string — and
// comparing paths would report that repair as "already valid", which is
// the one number an operator running this is actually reading.
const wasValid = existing ? await isThumbnailValid(existing) : false;
const thumbnailPath = await ensureThumbnail(photo);
if (!thumbnailPath) {
console.error(`✗ Could not generate thumbnail for ${label}`);
errorCount++;
continue;
}
if (wasValid && thumbnailPath === existing) {
skipCount++;
} else {
successCount++;
console.log(`✓ Generated thumbnail for ${label}`);
}
} catch (error) {
console.error(`✗ Failed for ${label}: ${error.message}`);
errorCount++;
}
}
console.log('\nThumbnail regeneration complete!');
console.log(`- Generated: ${successCount}`);
console.log(`- Skipped (already valid): ${skipCount}`);
console.log(`- Errors: ${errorCount}`);
console.log(`- Total processed: ${photos.length}`);
return { successCount, skipCount, errorCount };
}
// Parse command line arguments
const eventId = process.argv[2] ? parseInt(process.argv[2]) : null;
if (require.main === module) {
const args = process.argv.slice(2);
const eventArg = args.find((a) => !a.startsWith('--'));
const eventId = eventArg ? parseInt(eventArg, 10) : null;
// Run the script
regenerateThumbnails(eventId).then(() => {
console.log('Script completed successfully');
process.exit(0);
}).catch(error => {
console.error('Script failed:', error);
process.exit(1);
});
if (eventArg && !Number.isInteger(eventId)) {
console.error(`Not an event id: ${eventArg}`);
process.exit(1);
}
regenerateThumbnails(eventId)
.then(async (result) => {
await db.destroy();
// Exit status is the only thing a cron job reads. Resolving with a
// nonzero errorCount and still exiting 0 told automation the backfill
// was done when it had failed — which is how an unavailable mount stays
// unnoticed until someone opens a gallery.
if (result.errorCount) {
console.error(`Script completed with failures: ${result.errorCount} photo(s)`);
process.exit(1);
}
console.log('Script completed successfully');
process.exit(0);
})
.catch(async (error) => {
console.error('Script failed:', error);
await db.destroy().catch(() => {});
process.exit(1);
});
}
module.exports = { regenerateThumbnails };
+1
View File
@@ -240,6 +240,7 @@ router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), p
tables: result.tables,
filesRestored: result.filesRestored,
usesExternalMedia: result.usesExternalMedia,
crossEngine: result.crossEngine,
sessionInvalidated: true,
});
} catch (error) {
+2 -1
View File
@@ -28,6 +28,7 @@
*/
const express = require('express');
const { getStoragePath } = require('../config/storage');
const { body } = require('express-validator');
const path = require('path');
const fs = require('fs');
@@ -128,7 +129,7 @@ router.get(
);
const FRONTEND_URL_FALLBACK = 'https://app.example.com';
const DEV_TEST_DIR = () => path.join(process.cwd(), 'storage', 'business-docs', 'dev-test');
const DEV_TEST_DIR = () => path.join(getStoragePath(), 'business-docs', 'dev-test');
function fakeMoney(major, currency, locale = 'de') {
return new Intl.NumberFormat(locale === 'de' ? 'de-CH' : 'en-GB', {
+4 -2
View File
@@ -894,6 +894,7 @@ router.get(
// re-fetching here keeps the route a thin shim over the
// service rather than reaching inside its internals.
const { buildPdfFilename } = require('../utils/pdfFilename');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const inv = await db('invoices').where({ id }).first();
const customer = inv ? await db('customer_accounts').where({ id: inv.customer_account_id }).first() : null;
const filename = buildPdfFilename({
@@ -902,7 +903,7 @@ router.get(
fallback: `invoice-${id}`,
});
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${filename}"`);
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
res.send(buf);
})
);
@@ -919,6 +920,7 @@ router.post(
// the customer so the filename still reflects who the invoice
// is for; the number segment falls back to "invoice-preview".
const { buildPdfFilename } = require('../utils/pdfFilename');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const customer = payload.customerAccountId
? await db('customer_accounts').where({ id: payload.customerAccountId }).first()
: null;
@@ -928,7 +930,7 @@ router.post(
fallback: 'invoice-preview',
});
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${filename}"`);
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
res.send(buf);
})
);
+4 -2
View File
@@ -537,6 +537,7 @@ router.get(
const id = parseInt(req.params.id, 10);
const buf = await quoteService.renderQuotePdfBuffer(id);
const { buildPdfFilename } = require('../utils/pdfFilename');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const quote = await db('quotes').where({ id }).first();
const customer = quote ? await db('customer_accounts').where({ id: quote.customer_account_id }).first() : null;
const filename = buildPdfFilename({
@@ -545,7 +546,7 @@ router.get(
fallback: `quote-${id}`,
});
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${filename}"`);
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
res.send(buf);
})
);
@@ -559,6 +560,7 @@ router.post(
const payload = mapPayloadToService(req.body);
const buf = await quoteService.renderQuotePdfFromPayload(payload);
const { buildPdfFilename } = require('../utils/pdfFilename');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const customer = payload.customerAccountId
? await db('customer_accounts').where({ id: payload.customerAccountId }).first()
: null;
@@ -568,7 +570,7 @@ router.post(
fallback: 'quote-preview',
});
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${filename}"`);
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
res.send(buf);
})
);
+69 -29
View File
@@ -3,12 +3,27 @@ const router = express.Router();
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail, ensurePreviewImage } = require('../services/imageProcessor');
const path = require('path');
const fs = require('fs').promises;
const { ensureThumbnail, ensurePreviewImage } = require('../services/imageProcessor');
const { getStorage } = require('../services/storage');
const logger = require('../utils/logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/**
* Do these two stored paths address the same object?
*
* Compared the way the storage backends do, not as raw strings.
* LocalFsStorage._resolve and S3StorageBackend._key both fold `\` to `/` and
* strip a leading `./`, so a legacy thumbnail_path in any of those shapes is
* the SAME file as the freshly generated POSIX key while comparing unequal —
* and the "the key moved, delete the old one" branch below would then delete
* the thumbnail that had just been written.
*/
function sameStorageKey(a, b) {
const canonical = (key) => String(key)
.replace(/\\/g, '/')
.replace(/^\.?\/+/, '')
.replace(/\/+/g, '/');
return canonical(a) === canonical(b);
}
// Parse JSON-encoded setting values
function parseSettingValue(value) {
@@ -125,10 +140,21 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
try {
const { eventId } = req.body; // Optional: regenerate for specific event only
let query = db('photos').select('id', 'event_id', 'path');
// source_origin/external_relpath/filename are what ensureThumbnail branches
// on to resolve an external source off its mount instead of under
// events/active. thumbnail_path is selected so it can be nulled — see below.
let query = db('photos').select(
'id', 'event_id', 'path', 'media_type', 'mime_type', 'thumbnail_path',
'source_origin', 'external_relpath', 'filename'
);
if (eventId) {
query = query.where('event_id', eventId);
}
// Skip videos: their thumbnail is a poster frame from videoProcessor, so
// handing the container file to Sharp only ever produced an error per row.
query = query.where(function() {
this.whereNull('media_type').orWhere('media_type', '!=', 'video');
});
const photos = await query;
@@ -149,30 +175,38 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
for (const photo of photos) {
try {
const storagePath = getStoragePath();
const originalPath = path.join(storagePath, 'events/active', photo.path);
// Check if original file exists
try {
await fs.access(originalPath);
} catch (err) {
logger.warn(`Original file not found for photo ${photo.id}: ${originalPath}`);
errorCount++;
continue;
}
// Regenerate thumbnail
const thumbnailPath = await generateThumbnail(originalPath, { regenerate: true });
if (thumbnailPath) {
// Update database with new thumbnail path
await db('photos')
.where({ id: photo.id })
.update({
thumbnail_path: thumbnailPath,
updated_at: db.fn.now()
// Through ensureThumbnail, not a hand-rolled path (#1129). This route
// used to resolve every source as `storage/events/active/<path>` and
// fs.access it — a location that does not exist for external or
// reference rows, whose originals live under events.external_path. So
// every one of them failed the check and was counted as an error: on a
// reference install the endpoint rebuilt nothing while the UI reported
// success, because the response is sent before this loop starts.
//
// ensureThumbnail already resolves both source kinds, uses the
// per-photo ext<id>_ output name so two events referencing one NAS
// basename cannot clobber each other, and writes thumbnail_path back
// itself. Nulling thumbnail_path is what stops it short-circuiting on
// isThumbnailValid — necessary rather than cosmetic, because the old
// thumbnail is normally still readable at exactly the moment someone
// presses regenerate.
const newThumbnailPath = await ensureThumbnail({ ...photo, thumbnail_path: null });
if (newThumbnailPath) {
// Drop the superseded rendition when the key MOVED. On S3 the source
// is downloaded to a randomly-named temp file and, for non-RAW input,
// the key is derived from that name — so it differs every run, and
// nulling thumbnail_path hides the old key from everything that would
// otherwise clean it up. Guarded on the key actually changing: local
// storage is stable, and deleting the equal key would delete the file
// just written.
if (photo.thumbnail_path && !sameStorageKey(photo.thumbnail_path, newThumbnailPath)) {
await getStorage().delete(photo.thumbnail_path).catch((err) => {
logger.warn(
`Could not remove superseded thumbnail ${photo.thumbnail_path} for photo ${photo.id}: ${err.message}`
);
});
}
successCount++;
logger.info(`Regenerated thumbnail for photo ${photo.id}`);
} else {
@@ -201,7 +235,13 @@ router.post('/regenerate-previews', adminAuth, requirePermission('photos.edit'),
try {
const { eventId } = req.body;
let query = db('photos').select('id', 'event_id', 'path', 'media_type', 'mime_type', 'preview_path');
// source_origin/external_relpath/filename are what ensurePreviewImage
// branches on for external/reference rows (#1078) — without them every
// external photo looks managed here and generation is skipped.
let query = db('photos').select(
'id', 'event_id', 'path', 'media_type', 'mime_type', 'preview_path',
'source_origin', 'external_relpath', 'filename'
);
if (eventId) query = query.where('event_id', eventId);
// Skip videos — preview tier is image-only.
query = query.where(function() {
+12 -1
View File
@@ -725,7 +725,18 @@ router.get('/session', async (req, res) => {
expiresIn: Math.floor(remainingTime),
user: decoded.username || decoded.eventSlug,
eventSlug: decoded.eventSlug,
adminUsername: decoded.username
adminUsername: decoded.username,
// What KIND of gallery session this cookie is (#1149). The frontend
// kept this in sessionStorage, which is per-tab: reopening a gallery
// in a second tab lost 'client' while the cookie — and therefore the
// backend — still treated it as one. Reported from the token so a
// restored session knows what it actually is.
//
// viaCustomer marks a portal-minted token, which opens the gallery
// without the password. Also a credential, and it does not look like
// one: it runs at accessLevel 'guest'.
accessLevel: decoded.type === 'gallery' ? (decoded.accessLevel || 'guest') : undefined,
viaCustomer: decoded.type === 'gallery' ? decoded.via === 'customer' : undefined
});
} catch (err) {
res.json({
+4 -2
View File
@@ -566,6 +566,7 @@ router.get('/quotes/:id/pdf', customerAuth, async (req, res) => {
const quoteService = require('../services/quoteService');
const buf = await quoteService.renderQuotePdfBuffer(quote.id);
const { buildPdfFilename } = require('../utils/pdfFilename');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first();
const filename = buildPdfFilename({
docNumber: quote.quote_number,
@@ -573,7 +574,7 @@ router.get('/quotes/:id/pdf', customerAuth, async (req, res) => {
fallback: `quote-${quote.id}`,
});
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${filename}"`);
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
res.send(buf);
} catch (error) {
errorResponse(res, error, 500, 'Failed to render quote PDF');
@@ -597,6 +598,7 @@ router.get('/invoices/:id/pdf', customerAuth, async (req, res) => {
const invoiceService = require('../services/invoiceService');
const buf = await invoiceService.renderInvoicePdfBuffer(invoice.id);
const { buildPdfFilename } = require('../utils/pdfFilename');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const customer = await dbi('customer_accounts').where({ id: req.customer.id }).first();
const filename = buildPdfFilename({
docNumber: invoice.invoice_number,
@@ -604,7 +606,7 @@ router.get('/invoices/:id/pdf', customerAuth, async (req, res) => {
fallback: `invoice-${invoice.id}`,
});
res.set('Content-Type', 'application/pdf');
res.set('Content-Disposition', `inline; filename="${filename}"`);
res.set('Content-Disposition', buildContentDisposition(filename, 'inline'));
res.send(buf);
} catch (error) {
errorResponse(res, error, 500, 'Failed to render invoice PDF');
+80 -26
View File
@@ -29,6 +29,7 @@ const { resolveGuest } = require('../middleware/guestAuth');
const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
const { pipeStreamToResponse } = require('../utils/streamResponse');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
@@ -412,7 +413,10 @@ router.get('/:slug/show/:token/state', handleAsync(async (req, res) => {
router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res) => {
try {
// Get filter and sort parameters from query
const { filter, guest_id, sort = 'upload_date', order = 'desc' } = req.query;
// `guest_id` is deliberately NOT read from the query string: the viewer's
// own feedback is resolved from the request identity instead (see the
// filter block). The frontend still sends it; it is ignored.
const { filter, sort = 'upload_date', order = 'desc' } = req.query;
// Get watermark settings to generate cache-busting version for URLs
const watermarkSettings = await watermarkService.getWatermarkSettings();
@@ -457,6 +461,13 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// Execute the query
let photos = await photosQuery;
// Check if feedback should be visible to guests. Read BEFORE the filter
// block, not after: the filters below consult it, because a filter that
// selects on other people's feedback is a way of reading that feedback.
const feedbackService = require('../services/feedbackService');
const feedbackSettings = await feedbackService.getEventFeedbackSettings(req.event.id);
const showFeedbackToGuests = isClient || parseBooleanInput(feedbackSettings.show_feedback_to_guests, true);
// Apply filtering if requested (supports global stats + per-guest interactions)
if (filter) {
const filterTokens = new Set(
@@ -486,10 +497,37 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
});
};
// Whose feedback counts as "mine" for these filters.
//
// Resolved from the REQUEST, the same either/or the per-viewer is_liked
// query below uses — never from the `guest_id` query parameter. Two
// reasons, and both matter now that this is the only half left when
// feedback is hidden:
//
// - It never matched. The frontend's `gallery_guest_id` is a
// localStorage string it invents (`guest_<ts>_<rand>`) and never
// sends when submitting feedback; submissions store
// generateGuestIdentifier(req). So this lookup found nothing, and
// the filters only ever worked through the aggregate half — which
// is exactly the half now gated.
// - It is caller-controlled. Accepting an identifier from the query
// string would let anyone holding someone else's read their hidden
// memberships one token at a time, straight back through the gate.
//
// Hidden rows are excluded, matching what the viewer can actually SEE:
// getPhotoFeedback drops is_hidden for the guest's own feedback too.
// Unapproved rows are NOT excluded — a comment still in the moderation
// queue is still the viewer's own, and that same read keeps it.
let guestFeedbackByType = null;
if (guest_id) {
const guestFeedbackRows = await db('photo_feedback')
.where({ event_id: req.event.id, guest_identifier: guest_id })
{
const viewerFeedback = db('photo_feedback')
.where({ event_id: req.event.id, is_hidden: false });
if (req.guest?.id) {
viewerFeedback.where('guest_id', req.guest.id);
} else {
viewerFeedback.where('guest_identifier', generateGuestIdentifier(req));
}
const guestFeedbackRows = await viewerFeedback
.select('photo_id', 'feedback_type');
guestFeedbackByType = guestFeedbackRows.reduce((acc, row) => {
@@ -508,39 +546,50 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
}
};
// Every token below is an OR of two halves: what THIS viewer marked,
// and what ANYONE marked. The second half is other people's feedback,
// so it is gated on show_feedback_to_guests exactly like the counts
// this endpoint returns.
//
// Without the gate the setting only hides the numbers. A guest could
// still send `?filter=liked` and get back precisely the set of photos
// other people liked — the membership, one token at a time, which is
// most of what the counts would have told them. The viewer's own half
// is always theirs to filter by.
const includeAggregate = (predicate) => {
if (showFeedbackToGuests) includeBy(predicate);
};
if (filterTokens.has('liked')) {
includeGuestMatches('like');
includeBy(photo => (photo.like_count || 0) > 0);
includeAggregate(photo => (photo.like_count || 0) > 0);
}
if (filterTokens.has('favorited')) {
includeGuestMatches('favorite');
includeBy(photo => (photo.favorite_count || 0) > 0);
includeAggregate(photo => (photo.favorite_count || 0) > 0);
}
if (filterTokens.has('rated')) {
includeGuestMatches('rating');
includeBy(photo => (photo.average_rating || 0) > 0);
includeAggregate(photo => (photo.average_rating || 0) > 0);
}
if (filterTokens.has('commented')) {
includeGuestMatches('comment');
const commentedRows = await db('photo_feedback')
.where({ event_id: req.event.id, feedback_type: 'comment', is_approved: true, is_hidden: false })
.groupBy('photo_id')
.select('photo_id');
commentedRows.forEach(row => include.add(row.photo_id));
if (showFeedbackToGuests) {
const commentedRows = await db('photo_feedback')
.where({ event_id: req.event.id, feedback_type: 'comment', is_approved: true, is_hidden: false })
.groupBy('photo_id')
.select('photo_id');
commentedRows.forEach(row => include.add(row.photo_id));
}
}
photos = photos.filter(photo => include.has(photo.id));
}
}
// Check if feedback should be visible to guests
const feedbackService = require('../services/feedbackService');
const feedbackSettings = await feedbackService.getEventFeedbackSettings(req.event.id);
const showFeedbackToGuests = isClient || parseBooleanInput(feedbackSettings.show_feedback_to_guests, true);
// Then get comment counts separately
const commentCounts = await db('photo_feedback')
.whereIn('photo_id', photos.map(p => p.id))
@@ -568,7 +617,12 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
const likedPhotoIds = new Set();
if (showFeedbackToGuests && photos.length > 0) {
const likeQuery = db('photo_feedback')
.where({ event_id: req.event.id, feedback_type: 'like' })
// Hidden rows are not there, for the viewer's OWN feedback as much as
// anyone's (#1150). getPhotoFeedback drops them and
// updatePhotoFeedbackStats does not count them — leaving the heart
// filled was the one place that disagreed, so a like the photographer
// had hidden still showed as liked on a photo whose like_count was 0.
.where({ event_id: req.event.id, feedback_type: 'like', is_hidden: false })
.whereIn('photo_id', photos.map(p => p.id));
if (req.guest?.id) {
likeQuery.where('guest_id', req.guest.id);
@@ -1044,7 +1098,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
res.setHeader('Content-Length', zipInfo.size);
res.setHeader('Content-Disposition', `attachment; filename="${req.event.slug}.zip"`);
const stream = await storage.get(zipInfo.key);
stream.pipe(res);
pipeStreamToResponse(stream, res, { context: `prepared zip for event ${req.event.id}`, missingStatus: 410 });
// Log bulk download
db('access_logs').insert({
@@ -1505,7 +1559,7 @@ router.get('/:slug/photo/:photoId',
const file = useStorageBackend
? await storage.getRange(storageKey, start, end)
: fs.createReadStream(filePath, { start, end });
file.pipe(res);
pipeStreamToResponse(file, res, { context: `video range for photo ${photo.id}` });
} else {
res.writeHead(200, {
'Content-Length': fileSize,
@@ -1517,7 +1571,7 @@ router.get('/:slug/photo/:photoId',
const file = useStorageBackend
? await storage.get(storageKey)
: fs.createReadStream(filePath);
file.pipe(res);
pipeStreamToResponse(file, res, { context: `video for photo ${photo.id}` });
}
return;
}
@@ -1551,7 +1605,7 @@ router.get('/:slug/photo/:photoId',
'X-Protection-Level': 'basic'
});
const wmStream = await storage.get(photo.watermark_path);
return wmStream.pipe(res);
return pipeStreamToResponse(wmStream, res, { context: `watermarked photo ${photo.id}` });
}
} else {
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
@@ -1600,7 +1654,7 @@ router.get('/:slug/photo/:photoId',
res.set('Content-Length', stat.size);
if (photo.mime_type) res.set('Content-Type', photo.mime_type);
const stream = await storage.get(storageKey);
stream.pipe(res);
pipeStreamToResponse(stream, res, { context: `photo ${photo.id}` });
} else {
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
res.sendFile(absolutePath);
@@ -1695,7 +1749,7 @@ router.get('/:slug/thumbnail/:photoId',
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(thumbnailPath);
stream.pipe(res);
pipeStreamToResponse(stream, res, { context: `thumbnail for photo ${photoId}` });
}
} catch (error) {
errorResponse(res, error, 500, 'Failed to serve thumbnail');
@@ -1781,7 +1835,7 @@ router.get('/:slug/hero/:photoId',
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(heroPath);
stream.pipe(res);
pipeStreamToResponse(stream, res, { context: `hero for photo ${photoId}` });
}
} catch (error) {
logger.error('Error serving hero image:', {
@@ -1877,7 +1931,7 @@ router.get('/:slug/preview/:photoId',
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(previewPath);
stream.pipe(res);
pipeStreamToResponse(stream, res, { context: `preview for photo ${photoId}` });
}
} catch (error) {
logger.error('Error serving preview image:', {
+8 -1
View File
@@ -367,7 +367,14 @@ router.get('/:slug/my-feedback',
const query = db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.event_id', event.id);
.where('photo_feedback.event_id', event.id)
// Hidden rows are absent for the guest who left them too (#1150). In
// guest identity mode GalleryView builds its Liked/Favorited/Rated
// chips and their filters from THIS array rather than from is_liked,
// so without this a hidden like left an empty heart while the Liked
// chip still counted it and still surfaced the photo. Unapproved rows
// stay: a comment in the moderation queue is still the guest's own.
.where('photo_feedback.is_hidden', false);
// Prefer guest_id lookup when a verified guest token is present
// (per-person identity). Fall back to the device hash otherwise.
@@ -40,11 +40,17 @@
const fs = require('fs').promises;
const path = require('path');
const { getStoragePath } = require('../config/storage');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const backupService = require('./backupService');
const STORAGE_ROOT = () => process.env.STORAGE_PATH || path.join(process.cwd(), 'storage');
// The shared resolver, not a second `STORAGE_PATH || cwd` expression. With
// STORAGE_PATH unset the two disagree — getStoragePath() falls back
// module-relative while cwd is normally backend/ — and this diagnostic would
// then report the business-docs tree as missing while the backup walker, which
// uses the module-relative root, was backing it up correctly.
const STORAGE_ROOT = () => getStoragePath();
/**
* Top-level subdirectories we expect to find under STORAGE_PATH but
@@ -49,12 +49,18 @@
*/
const fs = require('fs');
const { getStoragePath } = require('../config/storage');
const crypto = require('crypto');
const path = require('path');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const STORAGE_ROOT = () => process.env.STORAGE_PATH || path.join(process.cwd(), 'storage');
// The shared resolver, not a second `STORAGE_PATH || cwd` expression. With
// STORAGE_PATH unset the two disagree — getStoragePath() falls back
// module-relative while cwd is normally backend/ — and this diagnostic would
// then report the business-docs tree as missing while the backup walker, which
// uses the module-relative root, was backing it up correctly.
const STORAGE_ROOT = () => getStoragePath();
/**
* Every column the verifier walks, declared once so the test suite
@@ -2,6 +2,7 @@
// module-level overview. Do not add behavior here without updating the entry re-exports.
const crypto = require('crypto');
const { getStoragePath } = require('../../config/storage');
const fs = require('fs');
const path = require('path');
const logger = require('../../utils/logger');
@@ -42,7 +43,7 @@ function sha256OfFile(filePath) {
async function persistContractPdf(contract, buffer, suffix = '') {
if (!contract.contract_number) return { filePath: null, sha256: null };
const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year));
const root = path.join(getStoragePath(), 'business-docs', 'contract', String(year));
fs.mkdirSync(root, { recursive: true });
// Always append a millisecond timestamp to the filename so writes
// never overwrite an earlier version on disk. Forensic preservation.
@@ -92,8 +93,7 @@ async function persistSignatureImage(contract, role, dataUrl) {
}
const ext = match[1] === 'jpeg' ? 'jpg' : 'png';
const root = path.join(
process.cwd(),
'storage',
getStoragePath(),
'business-docs',
'contract',
'signatures',
@@ -194,7 +194,7 @@ async function persistAuditCertificate(contract) {
try {
const { buffer } = await pdfStampService.renderAuditCertificate(ctx);
const year = (contract.issue_date ? new Date(contract.issue_date) : new Date()).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', 'contract', String(year));
const root = path.join(getStoragePath(), 'business-docs', 'contract', String(year));
fs.mkdirSync(root, { recursive: true });
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
const filePath = path.join(root, `${contract.contract_number}_audit_${stamp}.pdf`);
+45 -2
View File
@@ -124,7 +124,12 @@ class FeedbackService {
*/
async countGuestFeedback(eventId, feedbackType, guestId, guestIdentifier) {
const query = db('photo_feedback')
.where({ event_id: eventId, feedback_type: feedbackType });
// Hidden rows do not count against the guest's cap (#1150). They are
// absent everywhere else — the heart is empty, the tallies skip them,
// and submitFeedback now treats one as room for a fresh row. Counting
// them here would meet that fresh row with limit_reached and leave the
// control dead until the guest un-likes something they can still see.
.where({ event_id: eventId, feedback_type: feedbackType, is_hidden: false });
if (guestId) {
query.where('guest_id', guestId);
} else {
@@ -152,6 +157,13 @@ class FeedbackService {
photo_id: photoId,
event_id: eventId,
feedback_type,
// A hidden row is not there (#1150). Without this the guest saw an
// empty heart — every read surface treats hidden as absent — and
// clicking it found the hidden row and TOGGLED IT OFF, so the
// click appeared to do nothing and it took two more to get back to
// a filled heart. Skipping it makes the click create a fresh,
// visible row, which is what the guest is asking for.
is_hidden: false,
});
if (guest_id) {
duplicateQuery.where('guest_id', guest_id);
@@ -297,6 +309,11 @@ class FeedbackService {
const totalStats = await db('photo_feedback')
.where('event_id', eventId)
// Hidden rows do not count, the same rule the photo counters above
// already apply — without this the two halves of THIS response
// disagreed, and a hidden row preserved beside its replacement (#1150)
// is counted twice.
.where('is_hidden', false)
.select(
db.raw('COUNT(DISTINCT CASE WHEN feedback_type = ? THEN guest_identifier END) as unique_raters', ['rating']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_ratings', ['rating']),
@@ -379,7 +396,33 @@ class FeedbackService {
await db('photo_feedback')
.where('id', feedbackId)
.update(updates);
// Unhiding can collide with a replacement (#1150). A hidden row reads as
// absent, so the guest may well have re-added the same feedback in the
// meantime; making the original visible again would leave TWO visible
// rows for one guest on one photo — double-counted in the tallies, and
// needing two toggles to clear because each one deletes a single row.
//
// Needs a stable identity to scope by. With neither id nor identifier
// the fallback degrades to `guest_identifier IS NULL`, which is every
// identifier-less row on the photo — other people's, deleted. Nothing to
// converge in that case, so leave it alone. Comments are exempt: several
// from one guest on one photo is normal.
const collapseIdentity = feedback.guest_id || feedback.guest_identifier;
if (updates.is_hidden === false && feedback.feedback_type !== 'comment' && collapseIdentity) {
const superseded = db('photo_feedback')
.where({
photo_id: feedback.photo_id,
event_id: feedback.event_id,
feedback_type: feedback.feedback_type,
is_hidden: false,
})
.whereNot('id', feedbackId);
if (feedback.guest_id) superseded.where('guest_id', feedback.guest_id);
else superseded.where('guest_identifier', feedback.guest_identifier);
await superseded.delete();
}
// Update photo stats if visibility changed
await this.updatePhotoFeedbackStats(feedback.photo_id);
+77 -16
View File
@@ -133,10 +133,18 @@ async function generateThumbnail(imagePath, options = {}) {
// Get thumbnail settings
const settings = await getThumbnailSettings();
// Force regeneration: drop the existing object before writing the new one
if (options.regenerate) {
await storage.delete(thumbnailRelKey).catch(() => {});
}
// `options.regenerate` deliberately does NOT delete the existing object first
// (#1129).
//
// It used to, and the delete ran BEFORE sharp had even opened the source — so
// a source that could not be read (a NAS mount that blipped, a corrupt file)
// left the old thumbnail already gone and returned null, with the database
// still pointing at it. One bulk regeneration during a mount outage could
// therefore strip every canonical thumbnail in a reference gallery.
//
// Nothing is lost by dropping it: LocalFsStorage.put stages to a temp file and
// renames over the target, which replaces atomically, and an S3 put overwrites
// by key. The delete only added a window with no thumbnail at all.
try {
// First, verify the source image is complete and valid
@@ -192,9 +200,12 @@ async function generateThumbnail(imagePath, options = {}) {
const msg = (error && error.message) ? error.message : String(error);
logger.error(`Failed to generate thumbnail for ${sourceBasename}: ${msg}`);
// Clean up any partially uploaded object
await storage.delete(thumbnailRelKey).catch(() => {});
// No cleanup delete here either, for the same reason (#1129). This was
// "clean up any partially uploaded object", but there cannot be one:
// storage.put is the LAST statement in the try, every throw above it
// happens before anything is written, and put unlinks its own temp file on
// failure. The only object this could remove is the PREVIOUS, valid
// rendition — exactly the thumbnail a failed regeneration must leave alone.
return null;
}
}
@@ -498,7 +509,10 @@ async function ensureHeroImage(photo) {
* thumbnails or heroes.
*/
async function generatePreviewImage(imagePath, options = {}) {
const filename = path.basename(imagePath);
// outputBasename lets callers disambiguate sources that share a basename
// (external mounts, see ensurePreviewImage) — same contract as
// generateThumbnail.
const filename = options.outputBasename || path.basename(imagePath);
const previewFilename = `preview_${filename}`;
const previewRelKey = path.posix.join('previews', previewFilename);
const storage = getStorage();
@@ -579,17 +593,27 @@ async function isPreviewValid(previewPath) {
* Lazy-generate the preview image for a photo if missing or invalid.
* Returns the storage key or null on failure (callers fall back to
* the original URL so the lightbox never shows a broken image).
*
* Handles both managed photos (via the storage backend, possibly S3) and
* external/reference photos (#1078 sourced from a local mount outside the
* managed storage tree). Externals used to have no branch here at all:
* resolvePhotoStorageKey returns null for them by design, that null reached
* withLocalCopy, and the throw put every lightbox open back on the full-size
* original the exact cost the preview tier (#492) exists to avoid.
*/
async function ensurePreviewImage(photo) {
const { resolvePhotoStorageKey } = require('./photoResolver');
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
let sourceKey;
let event;
try {
const event = await db('events').where('id', photo.event_id).first();
sourceKey = resolvePhotoStorageKey(event, photo);
event = await db('events').where('id', photo.event_id).first();
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
logger.error(`Failed to resolve original key for preview (photo ${photo.id}): ${msg}`);
logger.error(`Failed to load event for preview (photo ${photo.id}): ${msg}`);
return null;
}
if (!event) {
logger.error(`ensurePreviewImage: event ${photo.event_id} not found for photo ${photo.id}`);
return null;
}
@@ -599,9 +623,46 @@ async function ensurePreviewImage(photo) {
logger.warn(`Invalid preview detected for photo ${photo.id}, regenerating…`);
}
const newPreviewPath = await withLocalCopy(sourceKey, (localPath) =>
generatePreviewImage(localPath, { regenerate: true })
);
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
let newPreviewPath;
if (isExternal) {
// Mirrors ensureThumbnail's external branch: the source is a direct fs
// read off the mount, so no withLocalCopy. The per-photo outputBasename
// keeps two events that reference the same NAS basename from clobbering
// each other's preview.
let localPath;
try {
localPath = resolvePhotoFilePath(event, photo);
} catch (e) {
logger.error(`Failed to resolve external file for preview (photo ${photo.id}): ${e.message}`);
return null;
}
const sourceBasename = path.basename(photo.external_relpath || photo.filename || `photo-${photo.id}`);
const outputBasename = `ext${photo.id}_${sourceBasename}`;
logger.info(`Ensuring preview for external photo ${photo.id} from ${localPath}`);
newPreviewPath = await generatePreviewImage(localPath, { regenerate: true, outputBasename });
} else {
let sourceKey;
try {
sourceKey = resolvePhotoStorageKey(event, photo);
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
logger.error(`Failed to resolve original key for preview (photo ${photo.id}): ${msg}`);
return null;
}
if (!sourceKey) {
// Reference-mode event holding a row with no source_origin: the mode
// falls back to the event's and resolvePhotoStorageKey returns null.
// Honour the documented null-on-failure contract instead of feeding
// null into withLocalCopy, which throws out of this function.
logger.warn(`No managed storage key for preview (photo ${photo.id}); skipping preview generation`);
return null;
}
newPreviewPath = await withLocalCopy(sourceKey, (localPath) =>
generatePreviewImage(localPath, { regenerate: true })
);
}
if (newPreviewPath) {
await db('photos').where({ id: photo.id }).update({ preview_path: newPreviewPath });
+2 -1
View File
@@ -2,6 +2,7 @@
// module-level overview. Do not add behavior here without updating the entry re-exports.
const { db, logActivity } = require('../../database/db');
const { getStoragePath } = require('../../config/storage');
const { getAppSetting } = require('../../utils/appSettings');
const { AppError } = require('../../utils/errors');
const { formatShortDate } = require('../../utils/dateFormatter');
@@ -132,7 +133,7 @@ async function applyReminder(invoice, lineItems, level, adminId) {
const fs = require('fs');
const path = require('path');
const year = new Date(fresh.issue_date).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', 'mahnung', String(year));
const root = path.join(getStoragePath(), 'business-docs', 'mahnung', String(year));
fs.mkdirSync(root, { recursive: true });
const mahnungPath = path.join(root, `${fresh.invoice_number}_mahnung_L${level}.pdf`);
fs.writeFileSync(mahnungPath, buffer);
+3 -2
View File
@@ -2,6 +2,7 @@
// module-level overview. Do not add behavior here without updating the entry re-exports.
const crypto = require('crypto');
const { getStoragePath } = require('../../config/storage');
const { db, logActivity } = require('../../database/db');
const logger = require('../../utils/logger');
const { AppError } = require('../../utils/errors');
@@ -107,7 +108,7 @@ async function sendInvoice(id, adminId) {
const fs = require('fs');
const path = require('path');
const year = new Date(invoice.issue_date).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year));
const root = path.join(getStoragePath(), 'business-docs', 'invoice', String(year));
fs.mkdirSync(root, { recursive: true });
const pdfPath = path.join(root, `${invoice.invoice_number}.pdf`);
fs.writeFileSync(pdfPath, buffer);
@@ -345,7 +346,7 @@ async function sendStorno(stornoId, adminId) {
const fs = require('fs');
const path = require('path');
const year = new Date(storno.issue_date).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', 'invoice', String(year));
const root = path.join(getStoragePath(), 'business-docs', 'invoice', String(year));
fs.mkdirSync(root, { recursive: true });
const pdfPath = path.join(root, `${storno.invoice_number}.pdf`);
fs.writeFileSync(pdfPath, buffer);
+9
View File
@@ -26,6 +26,7 @@
*/
const PDFDocument = require('pdfkit');
const { getStoragePath } = require('../config/storage');
const { SwissQRBill, Table } = require('swissqrbill/pdf');
const { t } = require('./pdf-i18n');
@@ -1349,8 +1350,16 @@ function registerCustomFonts(doc, issuer) {
if (issuer.pdfFontTtfPath) {
try {
const raw = issuer.pdfFontTtfPath;
// The configured storage root first; process.cwd()/storage stays on as a
// legacy fallback so installs predating STORAGE_PATH keep resolving.
// Compose makes the two the same directory, which is why only a custom
// STORAGE_PATH ever exposed this — the font just silently was not found
// and the document fell back to the built-in face.
const storageRoot = getStoragePath();
const candidates = [
path.isAbsolute(raw) ? raw : null,
path.join(storageRoot, raw.replace(/^\/+/, '')),
path.join(storageRoot, 'fonts', path.basename(raw)),
path.join(process.cwd(), 'storage', raw.replace(/^\/+/, '')),
path.join(process.cwd(), 'storage', 'fonts', path.basename(raw)),
].filter(Boolean);
+40 -23
View File
@@ -9,9 +9,10 @@
// email collides with the current account is overwritten with the current
// account's credentials (so the operator's known password keeps working).
//
// Same-engine only (pg↔pg / sqlite↔sqlite) and forward-only (an older backup
// restores onto a newer instance; a newer backup is refused). The target's own
// schema is used as-is — we never replay the backup's DDL.
// Same-engine (pg↔pg / sqlite↔sqlite) or the upgrade direction (sqlite → pg,
// #1041) — the reverse is refused. Forward-only (an older backup restores onto
// a newer instance; a newer backup is refused). The target's own schema is
// used as-is — we never replay the backup's DDL.
const fs = require('fs');
const fsp = require('fs').promises;
@@ -44,7 +45,7 @@ async function readManifestFromZip(picpeakPath) {
}
// Returns an array of human-readable blockers ([] = OK to restore).
async function validateManifest(manifest, { allowEngineSwitch = false } = {}) {
async function validateManifest(manifest) {
const errors = [];
if (!manifest || manifest.kind !== 'picpeak-backup') {
return ['This file is not a PicPeak backup (.picpeak).'];
@@ -53,14 +54,16 @@ async function validateManifest(manifest, { allowEngineSwitch = false } = {}) {
errors.push('This backup was created by a newer version of PicPeak. Update this instance first.');
}
const engine = isPostgres() ? 'pg' : 'sqlite';
// Cross-engine loads are opt-in and CLI-only (#1038). The archive format is
// engine-neutral NDJSON, but this path had never been exercised, so the
// upload/restore surface keeps refusing it — only
// scripts/migrate-sqlite-to-postgres.js, which exists to move an install
// between engines, passes allowEngineSwitch.
if (!allowEngineSwitch
&& manifest.database && manifest.database.engine && manifest.database.engine !== engine) {
errors.push(`Database engine mismatch: the backup is "${manifest.database.engine}" but this instance is "${engine}". Restore is only supported between matching engines.`);
const backupEngine = manifest.database && manifest.database.engine;
// Cross-engine restore is allowed in the UPGRADE direction only: a SQLite
// archive onto a Postgres instance (#1041) — the official small-install →
// full-stack migration path, same gate for the upload UI and
// scripts/migrate-sqlite-to-postgres.js. The reverse stays refused: pg
// archives carry ISO "T"/"Z" timestamps that SQLite would store as-is in
// text columns (the #1028/#1029 drift class), and engine downgrades are
// rarely intentional.
if (backupEngine && backupEngine !== engine && !(backupEngine === 'sqlite' && engine === 'pg')) {
errors.push(`Database engine mismatch: the backup is "${backupEngine}" but this instance is "${engine}". Cross-engine restore is only supported from a SQLite backup onto a PostgreSQL instance.`);
}
// Forward-only: the target schema must be at least as new as the backup's.
let targetLatest = null;
@@ -361,11 +364,11 @@ async function detectExternalMedia() {
* @param {Object} opts
* @param {string} opts.picpeakPath path to the uploaded/staged .picpeak
* @param {number} [opts.currentAdminId] admin to preserve across the wipe
* @returns {Promise<{restored:boolean, tables:number, filesRestored:number, usesExternalMedia:boolean, manifest:object}>}
* @returns {Promise<{restored:boolean, tables:number, filesRestored:number, usesExternalMedia:boolean, crossEngine:boolean, manifest:object}>}
*/
async function importFromPicpeak({ picpeakPath, currentAdminId, allowEngineSwitch = false }) {
async function importFromPicpeak({ picpeakPath, currentAdminId }) {
const manifest = await readManifestFromZip(picpeakPath);
const blockers = await validateManifest(manifest, { allowEngineSwitch });
const blockers = await validateManifest(manifest);
if (blockers.length) {
const err = new Error(blockers[0]);
err.statusCode = 400;
@@ -373,6 +376,16 @@ async function importFromPicpeak({ picpeakPath, currentAdminId, allowEngineSwitc
throw err;
}
// Archives predating the manifest engine field get the target's engine —
// i.e. the exact same-engine behavior. After validateManifest, a mismatch
// can only be sqlite → pg.
const targetEngine = isPostgres() ? 'pg' : 'sqlite';
const sourceEngine = (manifest.database && manifest.database.engine) || targetEngine;
const crossEngine = sourceEngine !== targetEngine;
if (crossEngine) {
logger.info(`[picpeak-import] cross-engine restore: ${sourceEngine} backup onto ${targetEngine} instance`);
}
const currentAdmin = currentAdminId
? await db('admin_users').where({ id: currentAdminId }).first()
: null;
@@ -404,21 +417,22 @@ async function importFromPicpeak({ picpeakPath, currentAdminId, allowEngineSwitc
logger.warn(`[picpeak-import] ignoring ${skipped.length} backup table(s) not present in this DB (or protected): ${skipped.join(', ')}`);
}
await replaceAllTables(tables, dataDir, currentAdmin, { crossEngine: allowEngineSwitch });
await replaceAllTables(tables, dataDir, currentAdmin, { crossEngine });
// Cross-engine only (#1038): rows are inserted with explicit ids, which
// leaves Postgres identity sequences at 1 and makes the next natural insert
// collide on the primary key. Same-engine restores keep today's behaviour
// untouched — this branch exists for scripts/migrate-sqlite-to-postgres.js.
if (allowEngineSwitch) await resyncSequences(tables);
// Post-commit fixup: rows are inserted with explicit ids, which leaves
// Postgres identity sequences behind, so the next natural insert collides
// on the primary key. Runs unconditionally, matching main — the guard used
// to be `if (allowEngineSwitch)`, which this change removes, and which also
// left a same-engine pg → pg restore with stale sequences.
await resyncSequences(tables);
const filesRestored = await restoreFiles(staging);
const usesExternalMedia = await detectExternalMedia();
logger.info(
`[picpeak-import] restored ${tables.length} tables, ${filesRestored} files (externalMedia=${usesExternalMedia})`
`[picpeak-import] restored ${tables.length} tables, ${filesRestored} files (externalMedia=${usesExternalMedia}, crossEngine=${crossEngine})`
);
return { restored: true, tables: tables.length, filesRestored, usesExternalMedia, manifest };
return { restored: true, tables: tables.length, filesRestored, usesExternalMedia, crossEngine, manifest };
} finally {
await fsp.rm(staging, { recursive: true, force: true }).catch(() => {});
}
@@ -431,5 +445,8 @@ module.exports = {
// exported for testing — the cross-engine coercion (#1038)
epochToIso,
coerceForTargetEngine,
typedColumnsFor,
reinjectCurrentAdmin,
// The cross-engine suite drives the post-restore sequence fixup directly.
resyncSequences,
};
+2 -1
View File
@@ -27,6 +27,7 @@
*/
const crypto = require('crypto');
const { getStoragePath } = require('../config/storage');
const { db, withRetry, logActivity } = require('../database/db');
const logger = require('../utils/logger');
const { getAppSetting } = require('../utils/appSettings');
@@ -1083,7 +1084,7 @@ async function persistDocPdf(type, doc, buffer) {
const number = doc.quote_number || doc.invoice_number;
if (!number) return null;
const year = (doc.issue_date ? new Date(doc.issue_date) : new Date()).getFullYear();
const root = path.join(process.cwd(), 'storage', 'business-docs', type, String(year));
const root = path.join(getStoragePath(), 'business-docs', type, String(year));
fs.mkdirSync(root, { recursive: true });
const filePath = path.join(root, `${number}.pdf`);
fs.writeFileSync(filePath, buffer);
@@ -65,6 +65,53 @@ describe('S3StorageAdapter', () => {
})
);
});
it('should configure connection and socket-inactivity timeouts by default', () => {
expect(S3Client).toHaveBeenCalledWith(
expect.objectContaining({
requestHandler: {
connectionTimeout: 120000,
socketTimeout: 60000
}
})
);
});
it('should keep connectionTimeout generous enough to survive socket-pool queuing', () => {
// connectionTimeout starts at request creation and only clears once a
// socket is assigned AND connected, so waiting for a free socket from
// the agent pool counts against it. A short value (e.g. 10s) fails
// every read under concurrent upload load. These timeouts bound an
// infinite hang; they are not latency targets.
const [[config]] = S3Client.mock.calls;
expect(config.requestHandler.connectionTimeout).toBeGreaterThanOrEqual(60000);
expect(config.requestHandler.socketTimeout).toBeGreaterThanOrEqual(30000);
});
it('should not set requestTimeout, which caps total duration and only warns', () => {
// requestTimeout would abort legitimate large uploads (it is a
// total-duration cap, not inactivity) and by default only logs a
// warning — it needs throwOnRequestTimeout to abort at all.
const [[config]] = S3Client.mock.calls;
expect(config.requestHandler).not.toHaveProperty('requestTimeout');
});
it('should allow overriding timeouts via config', () => {
new S3StorageAdapter({
bucket: 'test-bucket',
connectionTimeout: 5000,
socketTimeout: 30000
});
expect(S3Client).toHaveBeenLastCalledWith(
expect.objectContaining({
requestHandler: {
connectionTimeout: 5000,
socketTimeout: 30000
}
})
);
});
});
describe('testConnection', () => {
@@ -239,6 +286,30 @@ describe('S3StorageAdapter', () => {
s3Storage.config.retryDelay = originalDelay;
});
it('should retry when the request handler times out a dead connection', async () => {
// @smithy/node-http-handler rejects with name 'TimeoutError' for both
// its connection-timeout and socket-inactivity timeouts
const timeoutError = new Error('Connection timed out after 10000ms');
timeoutError.name = 'TimeoutError';
const operation = jest.fn()
.mockRejectedValueOnce(timeoutError)
.mockResolvedValueOnce('success');
const originalRandom = Math.random;
const originalDelay = s3Storage.config.retryDelay;
Math.random = jest.fn(() => 0);
s3Storage.config.retryDelay = 0;
const result = await s3Storage._retryOperation(operation);
expect(result).toBe('success');
expect(operation).toHaveBeenCalledTimes(2);
Math.random = originalRandom;
s3Storage.config.retryDelay = originalDelay;
});
it('should not retry on non-retryable errors', async () => {
const nonRetryableError = new Error('Invalid credentials');
nonRetryableError.code = 'InvalidCredentials';
+5
View File
@@ -27,6 +27,9 @@ let instance = null;
* STORAGE_S3_PREFIX namespace prefix inside the bucket
* STORAGE_S3_FORCE_PATH_STYLE=true|false (default: auto when endpoint set)
* STORAGE_S3_SSL=true|false (default: true)
* STORAGE_S3_CONNECTION_TIMEOUT ms to acquire+establish a socket (default 120000)
* STORAGE_S3_SOCKET_TIMEOUT ms of socket inactivity before a request
* fails and is retried (default 60000)
*/
function buildStorage() {
const backend = (process.env.STORAGE_BACKEND || 'local').toLowerCase();
@@ -48,6 +51,8 @@ function buildStorage() {
prefix: process.env.STORAGE_S3_PREFIX,
forcePathStyle: process.env.STORAGE_S3_FORCE_PATH_STYLE === 'true' ? true : undefined,
sslEnabled: process.env.STORAGE_S3_SSL !== 'false',
connectionTimeout: parseInt(process.env.STORAGE_S3_CONNECTION_TIMEOUT || '120000', 10),
socketTimeout: parseInt(process.env.STORAGE_S3_SOCKET_TIMEOUT || '60000', 10),
});
}
+31 -2
View File
@@ -40,6 +40,8 @@ class S3StorageAdapter extends stream.EventEmitter {
* @param {number} [config.partSize=10485760] - Part size for multipart upload (default 10MB)
* @param {number} [config.maxRetries=3] - Maximum number of retry attempts
* @param {number} [config.retryDelay=1000] - Initial retry delay in milliseconds
* @param {number} [config.connectionTimeout=120000] - Ms to acquire+establish a socket
* @param {number} [config.socketTimeout=60000] - Ms of socket inactivity before a request fails
*/
constructor(config) {
super();
@@ -58,13 +60,38 @@ class S3StorageAdapter extends stream.EventEmitter {
partSize: 10 * 1024 * 1024, // 10MB
maxRetries: 3,
retryDelay: 1000,
connectionTimeout: 120000,
socketTimeout: 60000,
...config
};
// Initialize S3 client
const s3Config = {
region: this.config.region,
forcePathStyle: this.config.forcePathStyle
forcePathStyle: this.config.forcePathStyle,
// Without timeouts a silently dropped connection leaves the request —
// and with it every queued upload — hanging forever.
//
// socketTimeout, NOT requestTimeout, is the right knob here:
// requestTimeout is a total-duration cap that would kill legitimate
// large uploads, and by default it only logs a warning (it needs
// throwOnRequestTimeout to abort at all). socketTimeout fires on
// socket INACTIVITY and destroys the request with a TimeoutError, so
// an active transfer of any size is safe and only a dead line trips.
//
// Both values are deliberately GENEROUS. connectionTimeout starts
// when the request object is created and only clears once a socket
// is both assigned and connected — so time spent queuing for a free
// socket from the agent pool (maxSockets 50) counts against it. A
// 10s value looks reasonable and is not: under concurrent uploads
// it expires while merely waiting in line, and every read (photo
// download, thumbnail, background thumbnailing) fails with
// TimeoutError. These timeouts exist to convert an INFINITE hang
// into a bounded failure, not to enforce latency targets.
requestHandler: {
connectionTimeout: this.config.connectionTimeout,
socketTimeout: this.config.socketTimeout
}
};
// Add credentials if provided
@@ -671,7 +698,9 @@ class S3StorageAdapter extends stream.EventEmitter {
}
// Check if error is retryable
const retryableErrors = ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ESOCKETTIMEDOUT', 'RequestTimeout', 'SlowDown', 'ServiceUnavailable', 'InternalError'];
// 'TimeoutError' is what @smithy/node-http-handler names both its
// connection-timeout and socket-inactivity rejections.
const retryableErrors = ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ESOCKETTIMEDOUT', 'RequestTimeout', 'TimeoutError', 'SlowDown', 'ServiceUnavailable', 'InternalError'];
const isRetryable = retryableErrors.some(code =>
error.code === code ||
error.name === code ||
+25 -1
View File
@@ -20,6 +20,19 @@
* - The PDF's internal `Title` metadata (Chrome's PDF viewer
* uses this as the default name when saving from a blob URL,
* where Content-Disposition can't reach)
*
* IMPORTANT (#1024): the preserved non-ASCII is exactly what a raw
* `filename="${...}"` header cannot carry. HTTP header values are
* latin1, so a customer label reaching a header directly either
* mangles (U+0080-U+00FF every German umlaut: `Müller` is sent as
* the byte 0xFC and read back as garbage) or throws ERR_INVALID_CHAR
* and 500s the request (anything above U+00FF Polish ł, Czech ř,
* Turkish ş, , Cyrillic, CJK, emoji).
*
* Never interpolate this result into a header. Pass it through
* `buildContentDisposition()` in utils/filenameSanitizer, which emits
* an ASCII fallback plus the RFC 5987 `filename*=UTF-8''…` form so the
* unicode name survives in browsers and the header stays legal.
*/
function sanitiseSegment(input, maxLen = 80) {
@@ -33,7 +46,18 @@ function sanitiseSegment(input, maxLen = 80) {
s = s.replace(/-+/g, '-');
// Trim leading/trailing dashes + dots.
s = s.replace(/^[-.]+|[-.]+$/g, '');
if (s.length > maxLen) s = s.slice(0, maxLen);
if (s.length > maxLen) {
s = s.slice(0, maxLen);
// slice() cuts UTF-16 code units, so a boundary landing inside an astral
// character (emoji, rarer CJK) leaves a dangling high surrogate. That is
// not merely cosmetic: the lone surrogate makes encodeURIComponent throw
// `URIError: URI malformed` inside buildContentDisposition, which 500s
// the PDF endpoint — the exact failure #1024 set out to remove, just via
// a different route. Drop the orphan rather than widening the cap, so the
// byte budget this limit exists to protect is unchanged.
const lastUnit = s.charCodeAt(s.length - 1);
if (lastUnit >= 0xD800 && lastUnit <= 0xDBFF) s = s.slice(0, -1);
}
return s;
}
+25 -5
View File
@@ -35,10 +35,15 @@
*
* **What the contract surface uses**
*
* Two roots:
* 1. `<cwd>/storage/business-docs/contract/<year>/` system-stamped
* PDFs (immutable as-sent + signed copies).
* 2. `<STORAGE_PATH or cwd/storage>/uploads/contracts/signed/`
* Three roots:
* 1. `<storage root>/business-docs/contract/` system-stamped PDFs
* (immutable as-sent + signed copies) and the signature images
* below them. This is where the writers persist.
* 2. `<cwd>/storage/business-docs/contract/` the same tree as written
* before the writers moved onto the shared storage resolver. Kept so
* pre-existing rows, whose absolute paths are in the database, still
* resolve; identical to (1) on a stock compose install.
* 3. `<storage root>/uploads/contracts/signed/`
* wet-upload PDFs (admin or customer-supplied).
*
* Both roots are constants from the operator's perspective; legitimate
@@ -48,6 +53,7 @@
const fs = require('fs');
const path = require('path');
const { AppError } = require('./errors');
const { getStoragePath } = require('../config/storage');
/**
* Resolve the canonical (symlink-followed) absolute path. Throws
@@ -111,8 +117,22 @@ function assertPathInside(filePath, allowedRoots) {
*/
function assertContractPdfPath(filePath) {
const cwd = process.cwd();
const storageRoot = process.env.STORAGE_PATH || path.join(cwd, 'storage');
// getStoragePath() rather than a second `STORAGE_PATH || cwd` expression:
// the two disagree whenever STORAGE_PATH is unset, because the shared
// resolver falls back module-relative (<repo>/storage) while this file used
// to fall back to <cwd>/storage — and the backend is normally started from
// backend/, so those are different directories. The writers use the shared
// resolver, so a guard with its own idea of the root refuses exactly the
// files it is meant to serve.
const storageRoot = getStoragePath();
return assertPathInside(filePath, [
// The configured storage root is where the contract writers persist, so it
// has to be allowed here or every generated PDF is refused with
// PATH_OUTSIDE_STORAGE the moment STORAGE_PATH is not <cwd>/storage. The
// cwd root stays alongside it: contracts written before the writers moved
// still live there, and their absolute paths are recorded in the database.
// Both collapse to the same directory on a stock compose install.
path.join(storageRoot, 'business-docs', 'contract'),
path.join(cwd, 'storage', 'business-docs', 'contract'),
path.join(storageRoot, 'uploads', 'contracts', 'signed'),
]);
+81
View File
@@ -0,0 +1,81 @@
/**
* Pipe a file/storage stream to an Express response without betting the
* process on the source still being there (#1128).
*
* `fs.createReadStream` what LocalFsStorage.get() returns is LAZY. It
* resolves immediately and only opens the file on a later tick, so an ENOENT
* arrives AFTER the `await` returned and outside the route's try/catch. An
* EventEmitter that emits 'error' with no listener throws, and an uncaught
* throw from an I/O callback is not something Express can catch: Node exits.
*
* That is how one missing thumbnail tier took down every gallery on the
* install the process died on the first grid load and only came back
* because Docker restarted it.
*
* The window is real and cannot be closed by a stat() beforehand: between the
* stat and the open, another request regenerating the same derivative can
* unlink it. So the handler is the fix, not the preflight.
*/
const logger = require('./logger');
/**
* @param {import('stream').Readable} stream source, already opened or lazy
* @param {import('express').Response} res
* @param {object} [options]
* @param {string} [options.context] what was being served, for the log line
* @param {number} [options.missingStatus=404] status when the source is gone
*/
function pipeStreamToResponse(stream, res, options = {}) {
const { context = 'file', missingStatus = 404 } = options;
stream.on('error', (err) => {
const gone = err && (err.code === 'ENOENT' || err.code === 'EISDIR');
// Once bytes are on the wire the status line is spent — there is no way to
// turn this into a 404. Destroy the response so the client sees a broken
// connection rather than a silently truncated image it would cache.
if (res.headersSent) {
logger.warn(`Stream failed mid-response for ${context}: ${err.message}`);
res.destroy(err);
return;
}
// Every header staged for the FILE now describes a body that will never
// be sent. They are cleared rather than left to Express, which does not
// overwrite a Content-Type that is already set — so without this the JSON
// error goes out as `image/jpeg`, or as an `application/zip` attachment
// that saves to disk as a corrupt download.
//
// Cache-Control matters most. The image routes stage `max-age=1800` (the
// hero route 3600), so a 404 from the regeneration race — the transient
// case this whole helper exists for — would be cached as a broken tile for
// up to an hour after the tier finished generating.
res.removeHeader('Content-Length');
res.removeHeader('ETag');
res.removeHeader('Content-Type');
res.removeHeader('Content-Disposition');
res.setHeader('Cache-Control', 'no-store');
if (gone) {
// Expected under the regeneration race — the tier existed at stat time
// and was replaced before the open. One broken tile, not an outage.
logger.warn(`Source vanished while serving ${context}: ${err.message}`);
res.status(missingStatus).json({ error: 'File not found' });
return;
}
logger.error(`Failed to stream ${context}`, { error: err.message, code: err.code });
res.status(500).json({ error: 'Failed to serve file' });
});
// A client that navigates away mid-download leaves the source handle open
// otherwise; on a gallery grid that is one leaked fd per abandoned tile.
res.on('close', () => {
if (!res.writableEnded) stream.destroy();
});
stream.pipe(res);
}
module.exports = { pipeStreamToResponse };
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.45.16",
"version": "3.46.4",
"type": "module",
"scripts": {
"dev": "vite",
@@ -16,6 +16,7 @@ interface RestoreResult {
tables: number;
filesRestored: number;
usesExternalMedia: boolean;
crossEngine?: boolean;
sessionInvalidated?: boolean;
}
@@ -137,7 +138,7 @@ export const PicpeakRestoreCard: React.FC = () => {
{t('backup.picpeak.restoreTitle', 'Restore from a .picpeak')}
</h3>
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
{t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Same database engine only.')}
{t('backup.picpeak.restoreIntro', 'Upload a .picpeak taken from this or another instance. Restoring a SQLite backup onto a PostgreSQL instance is supported (the upgrade path); other engine combinations must match.')}
</p>
<input ref={fileRef} type="file" accept=".picpeak,application/zip" className="hidden" onChange={onFilePick} />
<Button
@@ -163,6 +164,11 @@ export const PicpeakRestoreCard: React.FC = () => {
files: result.filesRestored,
})}
</p>
{result.crossEngine && (
<p className="mt-0.5 text-xs text-green-700 dark:text-green-300">
{t('backup.picpeak.crossEngineNote', 'Cross-engine restore: a SQLite backup was converted onto this PostgreSQL instance.')}
</p>
)}
{result.usesExternalMedia && (
<p className="mt-2 flex items-start gap-1 text-xs text-amber-800 dark:text-amber-300">
<AlertTriangle className="mt-0.5 h-4 w-4 flex-shrink-0" />
@@ -33,6 +33,20 @@ import { useQueryClient } from '@tanstack/react-query';
interface GalleryViewProps {
slug: string;
/**
* Whether this gallery is password-protected (#1149).
*
* Drives the Logout button. Logging out of a gallery that asks for nothing
* is meaningless there is no credential to drop and nothing to return to
* and it used to strand the visitor: GalleryPage's auto-login is a
* one-shot latch, so clearing the session left the page rendering its
* skeleton until a manual reload.
*
* A client (PIN) session still gets the button on a public gallery: that
* one IS a credential, and it is the only way back to the guest view. So is
* a customer-portal session.
*/
requiresPassword?: boolean;
event: {
id: number;
event_name: string;
@@ -67,9 +81,9 @@ const parseDefaultPhotoSort = (defaultSort?: string): { sortBy: 'date' | 'name'
}
};
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event, requiresPassword = true }) => {
const { t } = useTranslation();
const { logout, isClient } = useGalleryAuth();
const { logout, isClient, viaCustomer } = useGalleryAuth();
const { setTheme, theme } = useTheme();
const queryClient = useQueryClient();
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
@@ -705,6 +719,18 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
// Skip all wrapper elements (header, footer, sidebar, filters) for these layouts
const isFullPageLayout = theme.galleryLayout === 'gallery-premium' || theme.galleryLayout === 'gallery-story';
// Does this session hold something worth dropping? A password gallery and a
// PIN client obviously do, and so does a customer-portal session — its token
// opens the gallery without the password and lives for 24h in a cookie the
// customer logout does not clear.
//
// Read from the auth context, which resolves it from /auth/session on mount.
// accessLevel used to come from sessionStorage alone, which is per-TAB while
// the cookie is per-browser: a gallery reopened in a second tab lost
// 'client' while the backend went on serving it as one, and the gate would
// then hide the only control that clears the privileged cookie (#1149).
const showLogoutControl = requiresPassword || isClient || viaCustomer;
// For full-page layouts, render just the PhotoGridWithLayouts without any wrappers
if (isFullPageLayout) {
return (
@@ -745,7 +771,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
welcomeMessage={event.welcome_message}
onLogout={logout}
// Same gate as the standard layout below (#1149). These layouts
// render the button on the callback being present rather than on a
// showLogout flag, so withholding it is how the gate reaches them.
onLogout={showLogoutControl ? logout : undefined}
showOriginalFilename={showOriginalFilename}
/>
@@ -823,7 +852,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
heroLogoVisible={data?.event?.hero_logo_visible !== false}
heroLogoSize={data?.event?.hero_logo_size || undefined}
headerStyle={data?.event?.header_style || theme.headerStyle}
showLogout={true}
showLogout={showLogoutControl}
onLogout={logout}
// Old Download All header button is replaced by the new
// showHeaderDownload below — accent-coloured, always visible when
@@ -54,7 +54,7 @@ interface PhotoCardProps {
const PhotoCard: React.FC<PhotoCardProps> = ({
photo,
width,
height: _height,
height,
onClick,
onLike,
onSelect,
@@ -70,8 +70,13 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
allowLikes = false,
index
}) => {
// Note: height is passed but not used as we maintain aspect ratio via width
void _height;
// The height MasonryPhotoAlbum computed from photos.width/height is used, not
// discarded (#1130). Letting the tile size itself from the image meant the
// rendered shape came from whatever rendition was served — and with
// thumbnail_fit seeded to 'cover' (migration 040) every rendition is square,
// so the masonry laid out identical squares and was indistinguishable from
// the fixed grid. The photo's real aspect ratio is in the DB and is what the
// album already laid out against.
const { ref, inView } = useInView({
triggerOnce: true,
threshold: 0.1,
@@ -85,7 +90,7 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
<motion.div
ref={ref}
className={`gallery-premium-photo-card group ${isSelected ? 'selected' : ''}`}
style={{ width: '100%', height: 'auto', display: 'block' }}
style={{ width: '100%', height, display: 'block' }}
initial={{ opacity: 0, y: 20 }}
animate={inView ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 }}
transition={{ duration: 0.4, delay: Math.min(index * 0.05, 0.3) }}
@@ -95,8 +100,11 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
alt={photo.filename}
style={{ width, height: 'auto' }}
className="w-full h-auto object-cover"
// No inline height: the card now has a definite one, so the
// stylesheet's `.gallery-premium-photo-card img { height: 100% }` can
// finally apply and object-fit: cover crops a square rendition INTO the
// correctly-shaped tile, rather than the rendition dictating the shape.
className="w-full h-full object-cover"
loading="lazy"
isGallery={true}
slug={slug}
+18 -1
View File
@@ -39,6 +39,8 @@ interface GalleryAuthContextType {
isAuthenticated: boolean;
event: GalleryEvent | null;
accessLevel: GalleryAccessLevel;
/** Session was minted by the customer portal — credentialed, not a plain guest. */
viaCustomer: boolean;
isClient: boolean;
login: (slug: string, password?: string, recaptchaToken?: string | null) => Promise<void>;
clientLogin: (slug: string, password: string) => Promise<void>;
@@ -65,6 +67,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [event, setEvent] = useState<GalleryEvent | null>(null);
const [accessLevel, setAccessLevel] = useState<GalleryAccessLevel>('guest');
const [viaCustomer, setViaCustomer] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [routeError, setRouteError] = useState<string | null>(null);
@@ -204,13 +207,25 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
const initialise = async () => {
try {
setIsLoading(true);
const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>(
const sessionResponse = await api.get<{
valid: boolean; type: string; eventSlug?: string;
accessLevel?: GalleryAccessLevel; viaCustomer?: boolean;
}>(
'/auth/session',
{ params: { slug: currentSlug } }
);
if (sessionResponse.data?.valid && sessionResponse.data.type === 'gallery' && sessionResponse.data.eventSlug === currentSlug) {
setIsAuthenticated(true);
// The SERVER's view of this session, not the per-tab sessionStorage
// guess above (#1149). A second tab has no sessionStorage but the
// same cookie, so the stored value silently downgraded a client
// session to 'guest' while the backend kept serving it as a client.
if (sessionResponse.data.accessLevel === 'client') {
setAccessLevel('client');
sessionStorage.setItem(`gallery_access_level_${currentSlug}`, 'client');
}
setViaCustomer(Boolean(sessionResponse.data.viaCustomer));
// Always refresh from the server — the stored event from sessionStorage
// is shown above as an instant placeholder for perceived perf, but it
@@ -340,6 +355,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
setIsAuthenticated(false);
setEvent(null);
setAccessLevel('guest');
setViaCustomer(false);
clearActiveGallerySlug();
};
@@ -350,6 +366,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
event,
accessLevel,
isClient: accessLevel === 'client',
viaCustomer,
login,
clientLogin: clientLoginFn,
logout,
+5 -21
View File
@@ -352,6 +352,10 @@
}
},
"backup": {
"picpeak": {
"restoreIntro": "Laden Sie eine .picpeak-Datei von dieser oder einer anderen Instanz hoch. Die Wiederherstellung eines SQLite-Backups auf einer PostgreSQL-Instanz wird unterstützt (Upgrade-Pfad); ansonsten müssen die Datenbank-Engines übereinstimmen.",
"crossEngineNote": "Engine-übergreifende Wiederherstellung: Ein SQLite-Backup wurde auf diese PostgreSQL-Instanz übernommen."
},
"title": "Backup-Verwaltung",
"subtitle": "Verwalten Sie System-Backups, konfigurieren Sie automatisierte Backups und stellen Sie vorherige Backups wieder her.",
"tabs": {
@@ -1795,27 +1799,7 @@
"title": "E-Mail-Einstellungen"
},
"backup": {
"title": "Backup",
"picpeak": {
"title": "Portables Backup (.picpeak)",
"intro": "Laden Sie eine einzelne, in sich geschlossene Datei herunter und laden Sie sie auf einer anderen Instanz hoch, um diese zu klonen — komplett im Browser.",
"includePhotos": "Original-Galeriefotos einschließen (größere Datei)",
"secretsWarning": "Diese Datei enthält Geheimnisse im Klartext (E-Mail-Passwort, Admin-Zugangsdaten, API-Schlüssel). Bewahren Sie sie sicher auf und übertragen Sie sie nur über vertrauenswürdige Kanäle.",
"download": ".picpeak herunterladen",
"downloadFailed": "Die Backup-Datei konnte nicht erstellt werden.",
"restoreTitle": "Aus einer .picpeak wiederherstellen",
"restoreIntro": "Laden Sie eine .picpeak von dieser oder einer anderen Instanz hoch. Nur dieselbe Datenbank-Engine.",
"chooseFile": ".picpeak-Datei auswählen…",
"restoreDone": "Backup wiederhergestellt.",
"restoreFailed": "Wiederherstellung fehlgeschlagen.",
"restoreSummary": "{{tables}} Tabellen und {{files}} Dateien wiederhergestellt.",
"externalMediaNote": "Dieses Backup verweist auf eine externe Medienbibliothek. Stellen Sie sicher, dass das externe Medien-Routing auf dieser Instanz konfiguriert ist.",
"externalMediaLink": "Einrichtungsanleitung",
"reload": "App neu laden",
"confirmTitle": "Die Wiederherstellung löscht alle aktuellen Daten",
"confirmBody": "Dies ersetzt ALLE Daten auf dieser Instanz dauerhaft durch das hochgeladene Backup, mit Ausnahme Ihres aktuellen Kontos. Dies kann nicht rückgängig gemacht werden.",
"confirmRestore": "Löschen & wiederherstellen"
}
"title": "Backup"
},
"branding": {
"title": "Branding"
+5 -21
View File
@@ -1340,27 +1340,7 @@
"title": "Email Settings"
},
"backup": {
"title": "Backup",
"picpeak": {
"title": "Portable backup (.picpeak)",
"intro": "Download a single self-contained file, then upload it on another instance to clone this one — all through the browser.",
"includePhotos": "Include original gallery photos (larger file)",
"secretsWarning": "This file contains secrets in plain text (email password, admin credentials, API keys). Store it securely and only transfer it over trusted channels.",
"download": "Download .picpeak",
"downloadFailed": "Could not create the backup file.",
"restoreTitle": "Restore from a .picpeak",
"restoreIntro": "Upload a .picpeak taken from this or another instance. Same database engine only.",
"chooseFile": "Choose .picpeak file…",
"restoreDone": "Backup restored.",
"restoreFailed": "Restore failed.",
"restoreSummary": "{{tables}} tables and {{files}} files restored.",
"externalMediaNote": "This backup references an external-media library. Make sure external-media routing is configured on this instance.",
"externalMediaLink": "Setup guide",
"reload": "Reload app",
"confirmTitle": "Restore will delete all current data",
"confirmBody": "This permanently replaces ALL data on this instance with the uploaded backup, except your current account. This cannot be undone.",
"confirmRestore": "Delete & restore"
}
"title": "Backup"
},
"branding": {
"title": "Branding"
@@ -2798,6 +2778,10 @@
}
},
"backup": {
"picpeak": {
"restoreIntro": "Upload a .picpeak taken from this or another instance. Restoring a SQLite backup onto a PostgreSQL instance is supported (the upgrade path); other engine combinations must match.",
"crossEngineNote": "Cross-engine restore: a SQLite backup was converted onto this PostgreSQL instance."
},
"title": "Backup Management",
"subtitle": "Manage system backups, configure automated backups, and restore from previous backups.",
"tabs": {
+53
View File
@@ -716,3 +716,56 @@
margin-top: 0;
}
}
/*
* iOS Safari zooms the whole page in when a focused form control computes to
* less than 16px, and it does not zoom back out (#1105). Unlocking a gallery
* is a client-side transition rather than a document navigation, so the zoom
* the password field triggered carries straight into the gallery: the layout
* pans horizontally and the header actions sit off-screen until the visitor
* pinch-zooms out by hand.
*
* The lever is the font size, not the viewport meta adding maximum-scale=1
* would suppress the zoom by disabling pinch-to-zoom for everyone, which is an
* accessibility regression, so index.html deliberately omits it.
*
* 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 667956 CSS px, above any width you could call "phone". A
* max-width query fixes portrait and leaves every landscape phone (and iPad)
* still zooming. `pointer: coarse` is the population that actually has the
* behaviour; a mouse-driven desktop reports `fine` and keeps its 14px density.
*
* Deliberately NOT inside @layer, and deliberately more specific than a single
* utility class: `.input` is 14px and ~440 raw controls carry their own
* `text-sm`, so a rule that loses to a utility fixes almost nothing. The
* `:not()` on each selector is what buys that specificity without it,
* `select`/`textarea` (0,0,1) lose to `.text-sm` (0,1,0) and keep zooming,
* while `input` alone happens to win. Excluding checkbox and radio keeps
* font-size off controls that size their box from it.
*
* max(16px, 1em, 1rem) is a FLOOR, not a size. Writing a flat 16px would make
* controls that are already larger 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 - 1em follows the theme's body size, 1rem follows a browser default the
* visitor raised themselves, 16px catches Small themes and .text-sm controls:
*
* normal (body 16) 16px Large theme (body 18) 18px
* Small theme (body 14) 16px browser default 20px 20px
*
* The specificity that beats a utility class also beats a gallery's custom CSS
* (Theme -> Custom CSS), so `.input-themed { font-size: 20px }` lands at 16px
* on touch. That is unavoidable here rather than an oversight: nothing in CSS
* distinguishes a class that sets 14px from one that sets 20px, so a rule that
* loses to the second also loses to the first and fixes nothing. Overriding
* DOWNWARD is the point; upward is the cost. `font-size: 20px !important`
* still wins for anyone who wants it.
*/
@media (pointer: coarse) {
input:not([type="checkbox"]):not([type="radio"]),
select:not([hidden]),
textarea:not([hidden]) {
font-size: max(16px, 1em, 1rem);
}
}
+39 -2
View File
@@ -345,14 +345,51 @@ export const GalleryPage: React.FC = () => {
// Show gallery view if authenticated
if (isAuthenticated && event) {
return <GalleryView slug={gallerySlugForView} event={event} />;
return <GalleryView slug={gallerySlugForView} event={event} requiresPassword={requiresPassword} />;
}
// Public gallery: auto-login is in flight (or about to fire). Show the
// skeleton instead of the "publicly accessible — loading photos" card so
// visitors see one continuous skeleton until real photos appear (#321).
if (!requiresPassword) {
return <GallerySkeleton />;
if (!autoLoginAttempted || isLoggingIn) {
return <GallerySkeleton />;
}
// Auto-login has run and we are still not authenticated (#1149).
//
// Returning the skeleton here meant it never stopped: the effect above is
// latched on autoLoginAttempted and will not fire again, so the visitor
// sat on a loading gallery until they reloaded by hand. It also swallowed
// loginError completely — a public gallery that failed to open showed no
// reason, because this branch returns before the form that renders it.
//
// Reachable two ways: a failed or expired auto-login, and clearing the
// session from inside the gallery (the Logout button that should not have
// been there, or GalleryView's 401 handler). Retry re-arms the latch; it
// is a button rather than an automatic re-fire so a genuinely failing
// gallery cannot spin.
return (
<div className="min-h-screen flex items-center justify-center p-4"
style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<Card className="w-full max-w-md">
<CardContent className="p-6 text-center">
<AlertCircle className="w-10 h-10 mx-auto mb-3 text-muted-theme" />
<p className="text-base mb-4">
{loginError || t('gallery.failedToLoad', 'Failed to load gallery')}
</p>
<Button
onClick={() => {
setLoginError(null);
setAutoLoginAttempted(false);
}}
>
{t('gallery.tryAgain', 'Try again')}
</Button>
</CardContent>
</Card>
</div>
);
}
// Show login form
+3 -3
View File
@@ -18,9 +18,9 @@ async function createEventWithPhotos(page: Page, adminToken?: string, attempt =
},
});
expect(loginResponse.ok()).toBeTruthy();
const loginData = await loginResponse.json();
token = loginData.token;
expect(token).toBeTruthy();
const cookies = await page.context().cookies();
token = cookies.find((c) => c.name === 'admin_token')?.value;
expect(token, 'admin_token cookie missing from the login response').toBeTruthy();
}
const eventName = `Playwright Smoke ${Date.now()}`;
+8 -3
View File
@@ -40,9 +40,14 @@ async function adminLogin(page: Page): Promise<string> {
failOnStatusCode: false,
});
expect(res.ok()).toBeTruthy();
const json = await res.json();
expect(json.token).toBeTruthy();
return json.token;
// The admin JWT is delivered as the httpOnly `admin_token` cookie, not in
// the response body. Server-side the cookie and an Authorization: Bearer
// header are interchangeable, so read it back out of the context jar and
// keep threading it as a Bearer — every downstream call stays as it was.
const cookies = await page.context().cookies();
const token = cookies.find((c) => c.name === 'admin_token')?.value;
expect(token, 'admin_token cookie missing from the login response').toBeTruthy();
return token as string;
}
async function setCustomerPortalEnabled(page: Page, adminToken: string, enabled: boolean) {
@@ -8,9 +8,14 @@ async function getAdminToken(page: Page): Promise<string> {
data: { username: ADMIN_EMAIL, password: ADMIN_PASSWORD },
});
expect(res.ok()).toBeTruthy();
const body = await res.json();
expect(body.token).toBeTruthy();
return body.token;
// The admin JWT is delivered as the httpOnly `admin_token` cookie, not in
// the response body. Server-side the cookie and an Authorization: Bearer
// header are interchangeable, so read it back out of the context jar and
// keep threading it as a Bearer — every downstream call stays as it was.
const cookies = await page.context().cookies();
const token = cookies.find((c) => c.name === 'admin_token')?.value;
expect(token, 'admin_token cookie missing from the login response').toBeTruthy();
return token as string;
}
async function updateEventSettings(