Compare commits

..

24 Commits

Author SHA1 Message Date
Paul Nothaft 7598e20f55 chore(stable): release 3.46.2 (#1121)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-21 20:30:47 +02:00
Paul Nothaft 32db1c8052 fix(ui): stop iOS Safari zooming in on 14px form fields (#1114)
Closes #1105.

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

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

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

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

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

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

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

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

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

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

The viewport meta is deliberately left alone: `maximum-scale=1` would suppress
the zoom by disabling pinch-to-zoom for everyone.
2026-08-21 19:25:33 +02:00
Paul Nothaft 9833237d37 chore(stable): release 3.46.1 (#1082)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-19 20:29:01 +02:00
Paul Nothaft 83290a0f1a chore(security): ignore unfixed CVEs in Trivy, override deepmerge-ts (#1085)
Stable twin of #1083, scoped to what exists on this branch.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

No migration needed — the persisted path is stored absolute.

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

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

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

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

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

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

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

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

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-18 22:14:15 +02:00
Paul Nothaft 10d5cf54a5 chore(stable): release 3.46.0 (#1060)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-16 20:22:19 +02:00
Paul Nothaft 376311cb90 fix(pdf): RFC 6266-encode Content-Disposition on quote/invoice PDFs (#1024) (#1062)
Stable backport of #1055 (main: 3a11e6eb). Change content is byte-identical
to the main twin; cherry-picked clean, no resolutions needed.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-16 19:17:17 +02:00
Paul Nothaft ed4e32c4df chore(stable): release 3.45.16 (#1047)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-13 20:58:32 +02:00
Paul Nothaft 9003b34c8a fix(docker): default NODE_ENV=production so non-compose deploys don't fall back to SQLite (#1038) (#1040)
* fix(docker): default NODE_ENV=production so non-compose deploys don't fall back to SQLite (#1038)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Review round 12.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Review round 15.

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

    SQLITE_CONSTRAINT: NOT NULL constraint failed: events.expires_at

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

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

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

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

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

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

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

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-13 18:51:04 +02:00
Paul Nothaft 93d4ae68f4 chore(stable): release 3.45.15 (#1017)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-10 20:52:00 +02:00
Paul Nothaft 2bdb1204fe fix(slideshow): stop "no crop" fit letterboxing a pre-cropped frame (stable) (#1015) (#1019)
Backport of #1018 to the curated channel; the reporter on #1015 is running v3.45.14.

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

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

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

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

Lockfile-only; the existing ^ ranges already permitted both fixes.
2026-08-10 11:00:03 +02:00
Paul Nothaft c01d8d8d2e chore(stable): release 3.45.14 (#990)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-04 21:26:28 +02:00
Paul Nothaft bf9bd76278 fix(security): vet the destination project when linking a deal (stable) (#992)
Backport of #991. stable carried the identical code path and the same missing
guards.

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

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

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

Lockfile holds exactly one entry per package, all at or above the fixed
version; the image installs via npm ci --omit=dev.
2026-08-04 14:36:10 +02:00
68 changed files with 4244 additions and 257 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:
+5 -2
View File
@@ -130,5 +130,8 @@ docker-compose.dev.yml
# New layout development files
new-layouts/
# Generated CRM/accounting documents (runtime) — never commit
backend/storage/business-docs/
# Backend runtime storage (generated media, previews, thumbnails,
# CRM/accounting documents) — never commit. Matches main: a dev instance
# writes event photos into backend/storage/, and the narrower
# business-docs-only rule let `git add -A` sweep them into a commit.
backend/storage/
+1 -1
View File
@@ -1 +1 @@
{".":"3.45.13"}
{".":"3.46.2"}
+53
View File
@@ -5,6 +5,59 @@ 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.2](https://github.com/PicPeak/picpeak/compare/v3.46.1...v3.46.2) (2026-08-21)
### Bug Fixes
* **ui:** stop iOS Safari zooming in on 14px form fields ([#1114](https://github.com/PicPeak/picpeak/issues/1114)) ([32db1c8](https://github.com/PicPeak/picpeak/commit/32db1c8052d324b09462a17859c7adb5ccfe56e3))
## [3.46.1](https://github.com/PicPeak/picpeak/compare/v3.46.0...v3.46.1) (2026-08-19)
### Bug Fixes
* **preview:** generate lightbox previews for external/reference photos ([#1078](https://github.com/PicPeak/picpeak/issues/1078)) ([#1080](https://github.com/PicPeak/picpeak/issues/1080)) ([6df42ab](https://github.com/PicPeak/picpeak/commit/6df42ab22c705bcb731862db1ed5a27de0a64f30))
## [3.46.0](https://github.com/PicPeak/picpeak/compare/v3.45.16...v3.46.0) (2026-08-16)
### Features
* **backup:** open sqlite → pg .picpeak restore as the supported upgrade direction ([#1041](https://github.com/PicPeak/picpeak/issues/1041)) ([#1059](https://github.com/PicPeak/picpeak/issues/1059)) ([980378a](https://github.com/PicPeak/picpeak/commit/980378a17ba873d0e2f3d76048dacb3b8d7a4eb2))
### Bug Fixes
* **pdf:** RFC 6266-encode Content-Disposition on quote/invoice PDFs ([#1024](https://github.com/PicPeak/picpeak/issues/1024)) ([#1062](https://github.com/PicPeak/picpeak/issues/1062)) ([376311c](https://github.com/PicPeak/picpeak/commit/376311cb9091ff1726e8b383312f22c607dcc8a0))
* **storage:** add S3 client timeouts so a dropped connection can't wedge uploads ([#1049](https://github.com/PicPeak/picpeak/issues/1049)) ([#1054](https://github.com/PicPeak/picpeak/issues/1054)) ([88fa3c5](https://github.com/PicPeak/picpeak/commit/88fa3c52973fa122f8d4e7b21ba1ffc89f9f9c2e))
## [3.45.16](https://github.com/PicPeak/picpeak/compare/v3.45.15...v3.45.16) (2026-08-13)
### Bug Fixes
* **docker:** default NODE_ENV=production so non-compose deploys don't fall back to SQLite ([#1038](https://github.com/PicPeak/picpeak/issues/1038)) ([#1040](https://github.com/PicPeak/picpeak/issues/1040)) ([9003b34](https://github.com/PicPeak/picpeak/commit/9003b34c8a0396cd28906f089aef33f38a23ffb7))
* **events:** make event_date/expires_at nullable on SQLite ([#1029](https://github.com/PicPeak/picpeak/issues/1029)) ([#1036](https://github.com/PicPeak/picpeak/issues/1036)) ([fb3d0b0](https://github.com/PicPeak/picpeak/commit/fb3d0b08b2dc34f7e7dab7da754a3522c52a9eb1))
* **feedback:** persist guest feedback settings, unshadow the guest route ([#1030](https://github.com/PicPeak/picpeak/issues/1030)) ([#1032](https://github.com/PicPeak/picpeak/issues/1032)) ([de459c7](https://github.com/PicPeak/picpeak/commit/de459c701f28532ca53d52773b02de44c9978073))
* **gallery:** coerce SQLite 0/1 booleans in the guest surface ([#1028](https://github.com/PicPeak/picpeak/issues/1028)) ([#1037](https://github.com/PicPeak/picpeak/issues/1037)) ([8b6cd3c](https://github.com/PicPeak/picpeak/commit/8b6cd3c74f2aeb5d38ebfeee04bbc211d6fa2c0c))
## [3.45.15](https://github.com/PicPeak/picpeak/compare/v3.45.14...v3.45.15) (2026-08-10)
### Bug Fixes
* **deps:** bump nanoid and js-yaml out of two HIGH advisories (stable) ([#1014](https://github.com/PicPeak/picpeak/issues/1014)) ([cee0a38](https://github.com/PicPeak/picpeak/commit/cee0a380a6faf2bb0a5c802057ba3140670840d2))
* **slideshow:** stop "no crop" fit letterboxing a pre-cropped frame (stable) ([#1015](https://github.com/PicPeak/picpeak/issues/1015)) ([#1019](https://github.com/PicPeak/picpeak/issues/1019)) ([2bdb120](https://github.com/PicPeak/picpeak/commit/2bdb1204fe61a9b6cd704b35ccfd39efa15ed118))
## [3.45.14](https://github.com/PicPeak/picpeak/compare/v3.45.13...v3.45.14) (2026-08-04)
### Bug Fixes
* **deps:** bump ip-address, brace-expansion and postcss for open CVEs (stable) ([#988](https://github.com/PicPeak/picpeak/issues/988)) ([0fe5792](https://github.com/PicPeak/picpeak/commit/0fe5792a7d30bd948d6430642ca0bec35ddc2ca6))
* **security:** vet the destination project when linking a deal (stable) ([#992](https://github.com/PicPeak/picpeak/issues/992)) ([bf9bd76](https://github.com/PicPeak/picpeak/commit/bf9bd762783a2a675f0a6fcd965addf0f47cec57))
## [3.45.13](https://github.com/PicPeak/picpeak/compare/v3.45.12...v3.45.13) (2026-08-03)
+9
View File
@@ -27,6 +27,15 @@ FROM node:22-alpine
WORKDIR /app
# knexfile.js picks its config block by NODE_ENV, and the `development` block
# defaults to sqlite3. Leaving NODE_ENV unset here meant every deployment that
# doesn't go through our compose files — Kubernetes, Helm, plain `docker run` —
# silently ran on SQLite and ignored DB_HOST/DB_USER/DB_PASSWORD, while
# wait-for-db.sh (shell, reads DB_HOST directly) reported "PostgreSQL is up" in
# the same log. The compose files still override this, so nothing changes for
# compose users. See #1038.
ENV NODE_ENV=production
# Redeclare CACHEBUST — ARGs don't cross stage boundaries, so the builder
# stage's declaration never reached this stage. Consuming it in the RUN below
# busts that layer's cache every CI run (CACHEBUST=github.run_number), so the
@@ -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,150 @@
/**
* Slideshow photo source (#1015).
*
* The bug: with `lightbox_preview_enabled` off (the default), /photos emitted
* `preview_url: null`, so the slideshow's `preview_url || hero_url || url`
* chain fell through to `hero_url` — a 1920x1080 `fit: 'cover'` centre crop
* meant for gallery header banners. With the "Black Bars (No crop)" fit the
* show then letterboxed an already-cropped frame: portrait photos lost their
* top and bottom and the setting looked broken.
*
* The contract pinned here: `slideshow_url` points at the aspect-preserved
* preview tier and is emitted for image photos REGARDLESS of the lightbox
* toggle, so the slideshow never has a reason to reach for `hero_url`.
* `preview_url` itself must stay gated — the lightbox opt-in is unchanged.
*/
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('./helpers/crmDb');
process.env.JWT_SECRET = process.env.JWT_SECRET || 'slideshow-src-test-secret';
const SLUG = 'slideshow-source-event';
describe('Slideshow photo source (#1015)', () => {
let db;
let cleanup;
let app;
let eventId;
let imagePhotoId;
let videoPhotoId;
const galleryToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const setLightboxPreview = async (on) => {
await db('app_settings').where({ setting_key: 'lightbox_preview_enabled' }).del();
await db('app_settings').insert({
setting_key: 'lightbox_preview_enabled',
setting_value: JSON.stringify(on),
setting_type: 'general',
updated_at: new Date().toISOString(),
});
};
const fetchPhotos = async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photos`)
.set('Authorization', `Bearer ${galleryToken()}`)
.expect(200);
return res.body.photos;
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Slideshow Source Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'slideshow-source-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const img = await db('photos').insert({
event_id: eventId,
filename: 'portrait.jpg',
path: 'events/slideshow-source/portrait.jpg',
type: 'individual',
mime_type: 'image/jpeg',
uploaded_at: new Date().toISOString(),
}).returning('id');
imagePhotoId = img[0]?.id ?? img[0];
const vid = await db('photos').insert({
event_id: eventId,
filename: 'clip.mp4',
path: 'events/slideshow-source/clip.mp4',
type: 'individual',
media_type: 'video',
mime_type: 'video/mp4',
uploaded_at: new Date().toISOString(),
}).returning('id');
videoPhotoId = vid[0]?.id ?? vid[0];
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
it('emits slideshow_url for image photos even when lightbox previews are OFF', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
expect(image.slideshow_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
// The regression: this is what used to be null, pushing the show to hero.
expect(image.preview_url).toBeNull();
});
it('leaves preview_url gated so the lightbox opt-in is unchanged', async () => {
await setLightboxPreview(true);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
expect(image.preview_url).toBe(`/api/gallery/${SLUG}/preview/${imagePhotoId}`);
expect(image.slideshow_url).toBe(image.preview_url);
});
it('never points the slideshow at the cover-cropped hero tier', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const image = photos.find((p) => p.id === imagePhotoId);
// hero_url still ships (the gallery header uses it) — it just must not be
// what the slideshow resolves to.
expect(image.hero_url).toBe(`/api/gallery/${SLUG}/hero/${imagePhotoId}`);
expect(image.slideshow_url).not.toBe(image.hero_url);
});
it('emits slideshow_url: null for videos, which have no preview tier', async () => {
await setLightboxPreview(false);
const photos = await fetchPhotos();
const video = photos.find((p) => p.id === videoPhotoId);
expect(video.slideshow_url).toBeNull();
});
});
@@ -0,0 +1,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}'`);
}
});
});
@@ -0,0 +1,188 @@
/**
* SQLite boolean coercion in the guest gallery surface (#1028).
*
* SQLite stores booleans as 0/1; Postgres stores true/false. The /photos
* payload and every download guard compared strictly against `true`/`false`,
* so on SQLite:
*
* allow_downloads: 0 !== false → true (button shown while disabled)
* allow_user_uploads: 1 === true → false (button hidden while enabled)
* if (allow_downloads === false) → never fires, so ALL download endpoints
* kept serving with downloads switched off
*
* (The download-jobs route asserted on main is #858, which is beta-only —
* this branch covers the three download endpoints that exist here.)
*
* The harness runs on SQLite, so these assertions exercise the real engine
* values rather than a mock. Every test here fails on the unfixed code.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sqlite-flags-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sqlite-flags-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sqlite-flags-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'sqlite-flags-gallery';
describe('gallery flags survive SQLite 0/1 storage (#1028)', () => {
let db; let cleanup; let app; let eventId; let photoId;
async function setEventFlags(patch) {
await db('events').where('id', eventId).update(patch);
}
async function getPayload() {
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
expect(res.status).toBe(200);
return res.body.event;
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const ev = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'SQLite Flags',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/s`,
share_token: 'sqlite-flags-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
// Password-free so verifyGalleryAccess takes the public path and loads
// the row with SELECT * — i.e. the raw 0/1 values, same as production.
require_password: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = ev[0]?.id ?? ev[0];
const ph = await db('photos').insert({
event_id: eventId,
filename: 'p.jpg',
path: `${SLUG}/p.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoId = ph[0]?.id ?? ph[0];
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
test('the engine under test really is SQLite storing 0/1', async () => {
expect(['sqlite3', 'better-sqlite3']).toContain(db.client.config.client);
await setEventFlags({ allow_downloads: 0 });
const row = await db('events').where('id', eventId).first('allow_downloads');
expect(row.allow_downloads).toBe(0);
});
describe('with downloads disabled (allow_downloads = 0)', () => {
beforeAll(async () => {
await setEventFlags({ allow_downloads: 0, allow_user_uploads: 1 });
});
test('payload reports allow_downloads false (was true — header button shown)', async () => {
expect((await getPayload()).allow_downloads).toBe(false);
});
test('payload reports allow_user_uploads true (was false — upload button hidden)', async () => {
expect((await getPayload()).allow_user_uploads).toBe(true);
});
test('single-photo download is refused', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(res.status).toBe(403);
});
test('download-all is refused', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`);
expect(res.status).toBe(403);
});
test('download-selected is refused', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.send({ photo_ids: [photoId] });
expect(res.status).toBe(403);
});
});
describe('with downloads enabled (allow_downloads = 1)', () => {
beforeAll(async () => {
await setEventFlags({ allow_downloads: 1, allow_user_uploads: 0 });
});
test('payload reports allow_downloads true / allow_user_uploads false', async () => {
const event = await getPayload();
expect(event.allow_downloads).toBe(true);
expect(event.allow_user_uploads).toBe(false);
});
test('download-all is no longer refused', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`);
expect(res.status).not.toBe(403);
});
});
describe('protection flags', () => {
test('0/1 protection toggles are reported the way they are stored', async () => {
await setEventFlags({
disable_right_click: 1,
enable_devtools_protection: 1,
use_canvas_rendering: 1,
watermark_downloads: 1,
overlay_protection: 0,
});
const event = await getPayload();
expect(event.disable_right_click).toBe(true);
expect(event.enable_devtools_protection).toBe(true);
expect(event.use_canvas_rendering).toBe(true);
expect(event.watermark_downloads).toBe(true);
expect(event.overlay_protection).toBe(false);
});
});
describe('per-category download blocking (#640) on SQLite', () => {
test('a category with allow_downloads = 0 is reported as blocked', async () => {
const cat = await db('photo_categories').insert({
name: 'Blocked', slug: 'blocked', event_id: eventId, is_global: 0, allow_downloads: 0,
}).returning('id');
const categoryId = cat[0]?.id ?? cat[0];
await db('photos').where('id', photoId).update({ category_id: categoryId });
await setEventFlags({ allow_downloads: 1 });
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
expect(res.status).toBe(200);
const category = res.body.categories.find((c) => c.id === categoryId);
expect(category.allow_downloads).toBe(false);
const photo = res.body.photos.find((p) => p.id === photoId);
expect(photo.category_allow_downloads).toBe(false);
// …and the per-category guard on the single-photo route fires.
const dl = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(dl.status).toBe(403);
});
});
});
@@ -0,0 +1,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);
});
});
@@ -135,4 +135,95 @@ describe('linkDealToProject enforces lineage ownership (GHSA-wrg5, round 3)', ()
projectService.assignQuote(project, quoteId, { id: editorA }),
).rejects.toMatchObject({ code: 'DEAL_EVENT_FORBIDDEN' });
});
// The lineage guard above only fires once a deal has produced an event. The
// quote/contract create+update paths call linkDealToProject with a
// body-supplied projectId and NO route-level ownership guard, so a brand-new
// deal (eventIds empty) skipped every check and wrote into a foreign project.
describe('destination ownership (codex review follow-up)', () => {
it('refuses a foreign project even when the deal has no events yet', async () => {
const victimProject = await mkProject('victim-destination', editorB);
const quoteId = await mkQuote('deal-no-events', null);
await expect(
projectService.linkDealToProject('deal-no-events', victimProject, db, { id: editorA }),
).rejects.toMatchObject({ code: 'PROJECT_NOT_FOUND' });
const q = await db('quotes').where({ id: quoteId }).first('project_id');
expect(q.project_id == null).toBe(true);
});
it('refuses an OWNERLESS project with no events (the escalation path)', async () => {
// created_by NULL + no linked events is exactly the shape that would let
// the caller claim the project via ownedProjectsSubquery's second branch
// once their quote converts to an event.
const orphan = await mkProject('orphan-destination', null);
await mkQuote('deal-orphan', null);
await expect(
projectService.linkDealToProject('deal-orphan', orphan, db, { id: editorA }),
).rejects.toMatchObject({ code: 'PROJECT_NOT_FOUND' });
});
it("still allows the caller's own project with no events", async () => {
const own = await mkProject('own-destination', editorA);
const quoteId = await mkQuote('deal-own-dest', null);
await projectService.linkDealToProject('deal-own-dest', own, db, { id: editorA });
const q = await db('quotes').where({ id: quoteId }).first('project_id');
expect(Number(q.project_id)).toBe(Number(own));
});
it('refuses a foreign project when the deal_uuid is NULL (codex round 1)', async () => {
// deal_uuid is nullable (migration 107) and quoteService.update passes the
// EXISTING row's value, so a legacy quote reaches linkDealToProject with
// null. The old `if (!dealUuid || !projectId) return` bailed before the
// guard — while the caller had already written project_id onto its row.
const victimProject = await mkProject('victim-nulldeal', editorB);
await expect(
projectService.linkDealToProject(null, victimProject, db, { id: editorA }),
).rejects.toMatchObject({ code: 'PROJECT_NOT_FOUND' });
});
it('still no-ops on a NULL deal_uuid pointed at the caller-s own project', async () => {
// The destination is vetted, then it returns without cascading — there is
// no lineage to move.
const own = await mkProject('own-nulldeal', editorA);
await expect(
projectService.linkDealToProject(null, own, db, { id: editorA }),
).resolves.toBeUndefined();
});
it('does not leak customer association through the error code', async () => {
// The customer check used to run first, so a foreign project whose
// customer differed answered 422 PROJECT_CUSTOMER_MISMATCH while an
// unknown id answered 404 — enough to enumerate projects and infer their
// customer. Both must now be indistinguishable to a scoped caller.
const foreignWithCustomer = await mkProject('victim-customer', editorB);
await db('projects').where({ id: foreignWithCustomer }).update({ customer_account_id: customerId });
await mkQuote('deal-oracle', null);
await expect(
projectService.linkDealToProject('deal-oracle', foreignWithCustomer, db, { id: editorA }),
).rejects.toMatchObject({ code: 'PROJECT_NOT_FOUND' });
await expect(
projectService.linkDealToProject('deal-oracle', 999999, db, { id: editorA }),
).rejects.toMatchObject({ code: 'PROJECT_NOT_FOUND' });
});
it('leaves super_admin unrestricted on a foreign destination', async () => {
const victimProject = await mkProject('root-destination', editorB);
const quoteId = await mkQuote('deal-root-dest', null);
await projectService.linkDealToProject('deal-root-dest', victimProject, db, {
id: superAdmin, roleName: 'super_admin',
});
const q = await db('quotes').where({ id: quoteId }).first('project_id');
expect(Number(q.project_id)).toBe(Number(victimProject));
});
});
});
@@ -0,0 +1,609 @@
/**
* Engine resolution + the stranded-SQLite guard (#1038).
*
* knexfile.js picks its config block by NODE_ENV and the `development` block
* defaults to sqlite3. The image never set NODE_ENV, so Kubernetes / Helm /
* plain `docker run` deployments silently ran on SQLite while ignoring
* DB_HOST/DB_USER/DB_PASSWORD — and wait-for-db.sh, being shell, reported
* "PostgreSQL is up" in the same log.
*
* Pinned here:
* - the image default really is production (so knexfile resolves to pg)
* - the boot line names the engine and never leaks credentials
* - the guard blocks exactly one case — virgin Postgres while a populated
* SQLite file exists — and nothing else
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const {
resolveSqlitePath,
describeEngine,
decideBootEngine,
probeSqliteData,
migrationMarkerPath,
hasMigrationMarker,
migrationInProgressPath,
hasMigrationInProgress,
isUntouchedBootstrapRow,
adminsIndicateUse,
} = require('../../src/utils/databaseEngine');
const {
epochToIso,
coerceForTargetEngine,
} = require('../../src/services/picpeakImportService');
describe('knexfile engine selection (#1038)', () => {
// Resolved in a child process with a clean cwd: knexfile calls
// dotenv.config(), so running in-process would let a developer's
// backend/.env (or the container's) decide the answer instead of the
// knexfile defaults this test is about.
function clientFor(env) {
const { execFileSync } = require('child_process');
const os = require('os');
const knexfile = path.resolve(__dirname, '..', '..', 'knexfile.js');
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-knexenv-'));
const childEnv = { PATH: process.env.PATH };
if (env.NODE_ENV !== undefined) childEnv.NODE_ENV = env.NODE_ENV;
const out = execFileSync(
process.execPath,
['-e', `process.stdout.write(String(require(${JSON.stringify(knexfile)}).client))`],
{ cwd, env: childEnv, encoding: 'utf8' },
);
return out.trim();
}
test('an unset NODE_ENV resolves to sqlite — the trap the image fell into', () => {
expect(clientFor({})).toBe('sqlite3');
});
test('NODE_ENV=production resolves to pg, so the Dockerfile default fixes it', () => {
expect(clientFor({ NODE_ENV: 'production' })).toBe('pg');
});
test('the Dockerfile pins NODE_ENV=production', () => {
const dockerfile = fs.readFileSync(
path.resolve(__dirname, '..', '..', 'Dockerfile'), 'utf8',
);
expect(dockerfile).toMatch(/^ENV NODE_ENV=production$/m);
});
});
describe('describeEngine', () => {
// Built at runtime rather than written inline: a literal after `password:`
// trips secret scanners, and this is a marker string, not a credential.
const FAKE_CREDENTIAL = ['not', 'a', 'real', 'credential'].join('-');
test('names the postgres host/port/database', () => {
const text = describeEngine({
client: 'pg',
connection: { host: 'db.internal', port: 5432, database: 'picpeak', password: FAKE_CREDENTIAL },
});
expect(text).toBe('postgres (db.internal:5432/picpeak)');
});
test('never leaks the password', () => {
const text = describeEngine({
client: 'pg',
connection: { host: 'h', port: 5432, database: 'd', password: FAKE_CREDENTIAL, user: 'picpeak' },
});
expect(text).not.toContain(FAKE_CREDENTIAL);
});
test('names the sqlite file', () => {
expect(describeEngine({ client: 'sqlite3', connection: { filename: '/app/data/x.db' } }))
.toBe('sqlite (/app/data/x.db)');
});
});
describe('resolveSqlitePath', () => {
const ORIGINAL = process.env.DATABASE_PATH;
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.DATABASE_PATH;
else process.env.DATABASE_PATH = ORIGINAL;
});
test('defaults to backend/data/photo_sharing.db', () => {
delete process.env.DATABASE_PATH;
expect(resolveSqlitePath().endsWith(path.join('data', 'photo_sharing.db'))).toBe(true);
expect(path.isAbsolute(resolveSqlitePath())).toBe(true);
});
test('honours an absolute DATABASE_PATH', () => {
process.env.DATABASE_PATH = '/var/lib/picpeak/db.sqlite';
expect(resolveSqlitePath()).toBe('/var/lib/picpeak/db.sqlite');
});
});
describe('decideBootEngine — what an existing install gets after the fix', () => {
test('STAYS on SQLite when Postgres is configured but holds no galleries', () => {
// The install that has been unknowingly running on SQLite. Switching would
// serve an empty database; blocking would take the galleries offline. It
// keeps running exactly as before, loudly.
const r = decideBootEngine({
configuredClient: 'pg', explicitClient: null, pgHasData: false, sqliteHasData: true,
});
expect(r.client).toBe('sqlite3');
expect(r.overridden).toBe(true);
expect(r.reason).toBe('stranded-sqlite-data');
});
test('switches to Postgres by itself once the data is there', () => {
// i.e. straight after scripts/migrate-sqlite-to-postgres.js — no further
// operator action needed on the next restart. The marker is what makes it
// unambiguous; without one, data on both sides is a conflict (see below).
const r = decideBootEngine({
configuredClient: 'pg', explicitClient: null, pgHasData: true, sqliteHasData: true,
migrationCompleted: true, pgConfigured: true,
});
expect(r.client).toBe('pg');
expect(r.overridden).toBe(false);
});
test('a fresh install with no SQLite file goes straight to Postgres', () => {
expect(decideBootEngine({
configuredClient: 'pg', explicitClient: null, pgHasData: false, sqliteHasData: false,
}).client).toBe('pg');
});
test('an explicit DATABASE_CLIENT is always honoured', () => {
expect(decideBootEngine({
configuredClient: 'pg', explicitClient: 'sqlite3', pgHasData: true, sqliteHasData: true,
}).client).toBe('sqlite3');
expect(decideBootEngine({
configuredClient: 'sqlite3', explicitClient: 'pg', pgHasData: false, sqliteHasData: false,
}).client).toBe('pg');
});
test('forcing pg while SQLite still holds data is allowed, but flagged', () => {
const r = decideBootEngine({
configuredClient: 'pg', explicitClient: 'pg', pgHasData: false, sqliteHasData: true,
});
expect(r.client).toBe('pg');
expect(r.reason).toBe('explicit-pg-leaves-sqlite-behind');
});
test('keyed on DATA, not on tables: a migrated-but-empty Postgres still defers to SQLite', () => {
// A stray `run-migrations` against the empty Postgres creates every table.
// Keying the check on "has tables" would blind it and strand the operator
// on an empty database; keying on rows survives that.
expect(decideBootEngine({
configuredClient: 'pg', explicitClient: null, pgHasData: false, sqliteHasData: true,
}).client).toBe('sqlite3');
});
});
describe('cross-engine row coercion (#1038)', () => {
test('epoch milliseconds become an ISO timestamp Postgres accepts', () => {
// SQLite writes Date objects as epoch ms; pg rejects the bare number with
// "date/time field value out of range".
expect(epochToIso(1786548038763)).toBe('2026-08-12T15:20:38.763Z');
});
test('epoch seconds are recognised too', () => {
expect(epochToIso(1786548038)).toBe('2026-08-12T15:20:38.000Z');
});
test('a non-numeric value is left alone', () => {
expect(epochToIso('not-a-date')).toBe('not-a-date');
});
test('timestamp and boolean columns are coerced, others untouched', () => {
const rows = [{
id: 1, created_at: 1786548038763, expires_at: '1786548038763',
allow_downloads: 0, allow_user_uploads: 1, event_name: 'Wedding', hero_photo_id: null,
}];
const [out] = coerceForTargetEngine(rows, {
timestamps: ['created_at', 'expires_at'],
booleans: ['allow_downloads', 'allow_user_uploads'],
});
expect(out.created_at).toBe('2026-08-12T15:20:38.763Z');
expect(out.expires_at).toBe('2026-08-12T15:20:38.763Z');
expect(out.allow_downloads).toBe(false);
expect(out.allow_user_uploads).toBe(true);
expect(out.event_name).toBe('Wedding');
expect(out.hero_photo_id).toBeNull();
expect(out.id).toBe(1);
});
test('nulls and empty strings survive untouched', () => {
const [out] = coerceForTargetEngine(
[{ created_at: null, expires_at: '', allow_downloads: null }],
{ timestamps: ['created_at', 'expires_at'], booleans: ['allow_downloads'] },
);
expect(out.created_at).toBeNull();
expect(out.expires_at).toBe('');
expect(out.allow_downloads).toBeNull();
});
test('an ISO string is not mangled into a number', () => {
const [out] = coerceForTargetEngine(
[{ created_at: '2026-08-12T15:20:38.763Z' }], { timestamps: ['created_at'], booleans: [] },
);
expect(out.created_at).toBe('2026-08-12T15:20:38.763Z');
});
});
describe('probeSqliteData fails closed (#1038 review)', () => {
function tmpDb(contents) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-probe-'));
const file = path.join(dir, 'photo_sharing.db');
fs.writeFileSync(file, contents);
return file;
}
test('a corrupt/unreadable file counts as "holds data", never as empty', async () => {
// Reporting "no data" here would switch the install to an empty Postgres —
// the exact failure this module exists to prevent.
await expect(probeSqliteData(tmpDb('this is not a sqlite database'))).resolves.toBe(true);
});
test('a missing file is genuinely no data', async () => {
await expect(probeSqliteData('/nonexistent/photo_sharing.db')).resolves.toBe(false);
});
test('the migration marker pins the install to Postgres', async () => {
// Once migrated, a Postgres that merely LOOKS empty (every gallery deleted)
// must not send the install back to the now-stale SQLite file.
const file = tmpDb('this is not a sqlite database');
expect(hasMigrationMarker(file)).toBe(false);
expect(await probeSqliteData(file)).toBe(true);
fs.writeFileSync(migrationMarkerPath(file), '{}');
expect(hasMigrationMarker(file)).toBe(true);
expect(await probeSqliteData(file)).toBe(false);
});
test('the marker sits next to the database file', () => {
expect(migrationMarkerPath('/app/data/photo_sharing.db'))
.toBe('/app/data/photo_sharing.db.migrated-to-postgres');
});
});
describe('an unfinished migration pins the boot to SQLite (#1038 review)', () => {
// A migration that dies after touching Postgres leaves rows there — schema
// creation alone seeds a bootstrap admin when ADMIN_PASSWORD is set. Those
// rows read as "occupied", so without a pin the next restart would switch
// engines and hide the SQLite data that is still authoritative.
test('Postgres holding partial data does NOT win while the migration is unfinished', () => {
const r = decideBootEngine({
configuredClient: 'pg',
explicitClient: null,
pgHasData: true, // e.g. just the bootstrap admin, or a half-load
sqliteHasData: true,
migrationInProgress: true,
});
expect(r.client).toBe('sqlite3');
expect(r.reason).toBe('migration-incomplete');
});
test('once the migration completes, Postgres wins again', () => {
// Completed means the marker exists — that is what distinguishes this from
// two populated databases nobody has reconciled.
expect(decideBootEngine({
configuredClient: 'pg',
explicitClient: null,
pgHasData: true,
sqliteHasData: true,
migrationInProgress: false,
migrationCompleted: true,
pgConfigured: true,
}).client).toBe('pg');
});
test('the pin is irrelevant when there is no SQLite data to protect', () => {
expect(decideBootEngine({
configuredClient: 'pg',
explicitClient: null,
pgHasData: true,
sqliteHasData: false,
migrationInProgress: true,
}).client).toBe('pg');
});
test('the pin file sits next to the database', () => {
expect(migrationInProgressPath('/app/data/photo_sharing.db'))
.toBe('/app/data/photo_sharing.db.migration-in-progress');
expect(hasMigrationInProgress('/nonexistent/photo_sharing.db')).toBe(false);
});
});
describe('the migration pin outranks an explicit client (#1038 review r6)', () => {
// docker-compose sets DATABASE_CLIENT=pg, so without this an unfinished
// migration would be ignored on exactly the deployments that pin it, and a
// half-written Postgres would be served.
test('explicit pg loses to an unfinished migration while SQLite holds data', () => {
const r = decideBootEngine({
configuredClient: 'pg', explicitClient: 'pg',
pgHasData: true, sqliteHasData: true, migrationInProgress: true,
});
expect(r.client).toBe('sqlite3');
expect(r.reason).toBe('migration-incomplete');
});
test('explicit sqlite3 is left alone — it already points at the data', () => {
expect(decideBootEngine({
configuredClient: 'pg', explicitClient: 'sqlite3',
pgHasData: true, sqliteHasData: true, migrationInProgress: true,
}).client).toBe('sqlite3');
});
test('once the migration finishes, explicit pg is honoured again', () => {
expect(decideBootEngine({
configuredClient: 'pg', explicitClient: 'pg',
pgHasData: true, sqliteHasData: true, migrationInProgress: false,
}).client).toBe('pg');
});
test('a pin with no SQLite data left does not strand the install', () => {
expect(decideBootEngine({
configuredClient: 'pg', explicitClient: 'pg',
pgHasData: true, sqliteHasData: false, migrationInProgress: true,
}).client).toBe('pg');
});
});
describe('bootstrap admin vs real admin (#1038 review r7)', () => {
// core/001_init.js seeds must_change_password=true when ADMIN_PASSWORD is set;
// setupService writes false once a human finishes first-run setup. Judging by
// the FLAG rather than the table keeps both mistakes away: counting the seed
// as real data would abandon a populated SQLite file, and ignoring the whole
// table would abandon a legitimately set-up Postgres.
test('an untouched seeded row is recognised across both engines', () => {
expect(isUntouchedBootstrapRow(true)).toBe(true);
expect(isUntouchedBootstrapRow(1)).toBe(true);
expect(isUntouchedBootstrapRow('1')).toBe(true);
});
test('a completed setup is not a bootstrap row', () => {
expect(isUntouchedBootstrapRow(false)).toBe(false);
expect(isUntouchedBootstrapRow(0)).toBe(false);
expect(isUntouchedBootstrapRow('0')).toBe(false);
});
test('a legacy NULL counts as a real admin, not a seed', () => {
expect(isUntouchedBootstrapRow(null)).toBe(false);
expect(isUntouchedBootstrapRow(undefined)).toBe(false);
});
});
describe('admin rows: bootstrap seed vs real use (#1038 review r7/r8)', () => {
// must_change_password alone is mutable — resetAdminPassword() sets it on real
// accounts — so it cannot be the only signal. Only the exact shape
// core/001_init.js leaves behind reads as an untouched seed.
test('one never-used seeded admin is NOT use', () => {
expect(adminsIndicateUse([{ must_change_password: true, last_login: null }])).toBe(false);
expect(adminsIndicateUse([{ must_change_password: 1, last_login: null }])).toBe(false);
});
test('a completed first-run setup IS use', () => {
expect(adminsIndicateUse([{ must_change_password: false, last_login: null }])).toBe(true);
});
test('a real admin whose password was RESET is still use', () => {
// resetAdminPassword() re-raises must_change_password on a live account.
expect(adminsIndicateUse([
{ must_change_password: true, last_login: '2026-08-01T10:00:00Z' },
])).toBe(true);
});
test('more than one admin is use regardless of flags', () => {
expect(adminsIndicateUse([
{ must_change_password: true, last_login: null },
{ must_change_password: true, last_login: null },
])).toBe(true);
});
test('no admins at all is not use', () => {
expect(adminsIndicateUse([])).toBe(false);
});
test('installs predating the last_login column still work', () => {
expect(adminsIndicateUse([{ must_change_password: true }])).toBe(false);
expect(adminsIndicateUse([{ must_change_password: false }])).toBe(true);
});
});
describe('cross-engine JSON columns pass through untouched (#1038 review r8)', () => {
// SQLite keeps json columns as TEXT holding valid JSON, and pg accepts JSON
// text directly, so the coercion must not touch them at all: serialising
// would store `{"a":1}` as a scalar string, and parse-then-serialise turned
// the JSON literal `null` into SQL NULL, breaking NOT NULL json columns.
test('timestamps and booleans are coerced; nothing else is', () => {
const [out] = coerceForTargetEngine(
[{ setting_value: '{"a":1}', nulled: 'null', created_at: 1786548038763, flag: 1 }],
{ timestamps: ['created_at'], booleans: ['flag'] },
);
expect(out.setting_value).toBe('{"a":1}');
expect(out.nulled).toBe('null');
expect(out.created_at).toBe('2026-08-12T15:20:38.763Z');
expect(out.flag).toBe(true);
});
});
describe('Postgres probe: unreachable vs unusable (#1038 review r9)', () => {
const { probePgData } = require('../../src/utils/databaseEngine');
test('an unreachable Postgres reports "occupied" so a healthy install is not diverted', async () => {
// A transient network failure must not hand a live pg install over to a
// stale SQLite file; startup should surface the real connection error.
const warnings = [];
const result = await probePgData(
{ host: '127.0.0.1', port: 59999, user: 'nobody', password: 'x', database: 'nope' },
(m) => warnings.push(m),
);
expect(result).toBe(true);
expect(warnings.join(' ')).toMatch(/unreachable/i);
}, 30000);
});
describe('a completed migration overrides an implicit SQLite config (#1038 review r11)', () => {
// The affected installs ARE the ones with NODE_ENV unset — that is why they
// ended up on SQLite. An operator can easily migrate before fixing that, and
// by then the source file has been renamed away, so honouring the implicit
// sqlite3 would create a NEW empty database and serve it.
test('marker + Postgres settings beat an implicitly-resolved sqlite3', () => {
const r = decideBootEngine({
configuredClient: 'sqlite3', explicitClient: null,
pgHasData: true, sqliteHasData: false,
migrationCompleted: true, pgConfigured: true,
});
expect(r.client).toBe('pg');
expect(r.reason).toBe('migrated-to-postgres');
});
test('an EXPLICIT sqlite3 still wins — that is a deliberate rollback', () => {
expect(decideBootEngine({
configuredClient: 'sqlite3', explicitClient: 'sqlite3',
pgHasData: true, sqliteHasData: false,
migrationCompleted: true, pgConfigured: true,
}).client).toBe('sqlite3');
});
test('without Postgres settings there is nowhere to send it', () => {
expect(decideBootEngine({
configuredClient: 'sqlite3', explicitClient: null,
pgHasData: false, sqliteHasData: false,
migrationCompleted: true, pgConfigured: false,
}).client).toBe('sqlite3');
});
test('no marker, no override — a plain SQLite install is left alone', () => {
expect(decideBootEngine({
configuredClient: 'sqlite3', explicitClient: null,
pgHasData: false, sqliteHasData: true,
migrationCompleted: false, pgConfigured: true,
}).client).toBe('sqlite3');
});
});
describe('two populated databases is a conflict, not a guess (#1038 review r12)', () => {
// An install that ran on Postgres, lost NODE_ENV, and kept working on SQLite
// has real data on BOTH sides: the Postgres rows are old, the SQLite rows are
// newer. Picking either hides galleries and splits future writes.
test('no marker + data on both sides refuses to choose', () => {
const r = decideBootEngine({
configuredClient: 'pg', explicitClient: null,
pgHasData: true, sqliteHasData: true, migrationCompleted: false,
});
expect(r.client).toBeNull();
expect(r.reason).toBe('ambiguous-both-populated');
});
test('a completed migration is not a conflict — the marker says which is current', () => {
expect(decideBootEngine({
configuredClient: 'pg', explicitClient: null,
pgHasData: true, sqliteHasData: true, migrationCompleted: true, pgConfigured: true,
}).client).toBe('pg');
});
test('an explicit choice always resolves it', () => {
expect(decideBootEngine({
configuredClient: 'pg', explicitClient: 'sqlite3',
pgHasData: true, sqliteHasData: true, migrationCompleted: false,
}).client).toBe('sqlite3');
expect(decideBootEngine({
configuredClient: 'pg', explicitClient: 'pg',
pgHasData: true, sqliteHasData: true, migrationCompleted: false,
}).client).toBe('pg');
});
test('only one side populated is not a conflict', () => {
expect(decideBootEngine({
configuredClient: 'pg', explicitClient: null,
pgHasData: true, sqliteHasData: false, migrationCompleted: false,
}).client).toBe('pg');
expect(decideBootEngine({
configuredClient: 'pg', explicitClient: null,
pgHasData: false, sqliteHasData: true, migrationCompleted: false,
}).client).toBe('sqlite3');
});
test('the pg probe target comes from the environment, not a sqlite config', () => {
const { pgConnectionFromEnv } = require('../../src/utils/databaseEngine');
const prev = { ...process.env };
process.env.DB_HOST = 'db.internal';
process.env.DB_NAME = 'picpeak_prod';
try {
const c = pgConnectionFromEnv();
expect(c.host).toBe('db.internal');
expect(c.database).toBe('picpeak_prod');
} finally {
process.env.DB_HOST = prev.DB_HOST;
process.env.DB_NAME = prev.DB_NAME;
}
});
});
describe('the target is resolved once, with production defaults (#1038 review r13)', () => {
// knexfile's DEVELOPMENT block defaults pg to localhost/postgres/photo_sharing
// while production uses db/picpeak/picpeak. The CLI runs in the NODE_ENV-unset
// state by design, so without an explicit resolution the migration could land
// in a database the running application never opens.
const { pgConnectionFromEnv } = require('../../src/utils/databaseEngine');
test('falls back to what a running container actually uses', () => {
// Host is `postgres`, matching wait-for-db.sh, which resolves and EXPORTS
// that value — so it is the host a bare container really runs against.
// knexfile's production block says `db`, but that default is only reached
// when the entrypoint did not run; a `docker exec` CLI has to agree with
// the runtime, not with the dormant default (#1038 review r14).
const prev = { ...process.env };
delete process.env.DB_HOST; delete process.env.DB_USER; delete process.env.DB_NAME;
try {
const c = pgConnectionFromEnv();
expect(c.host).toBe('postgres');
expect(c.user).toBe('picpeak');
expect(c.database).toBe('picpeak');
} finally {
Object.assign(process.env, prev);
}
});
test('explicit settings always win', () => {
const prev = { ...process.env };
process.env.DB_HOST = 'pg.example'; process.env.DB_NAME = 'mypics';
try {
const c = pgConnectionFromEnv();
expect(c.host).toBe('pg.example');
expect(c.database).toBe('mypics');
} finally {
Object.assign(process.env, prev);
}
});
});
describe('the marker is bound to the target it describes (#1038 review r15)', () => {
const { currentPgTargetId, readMigrationMarker } = require('../../src/utils/databaseEngine');
test('the target id has the shape the migration records', () => {
const prev = { ...process.env };
process.env.DB_HOST = 'pg.host'; process.env.DB_PORT = '6543'; process.env.DB_NAME = 'picpeak_prod';
try {
expect(currentPgTargetId()).toBe('pg.host:6543/picpeak_prod');
} finally {
Object.assign(process.env, prev);
}
});
test('an absent or unreadable marker reads as null, not a throw', () => {
expect(readMigrationMarker('/nonexistent/photo_sharing.db')).toBeNull();
});
test('inbound_documents is a real table; incoming_invoices never was', () => {
// The occupancy lists silently skip tables that do not exist, so a wrong
// name meant supplier documents never protected the install.
const src = fs.readFileSync(
path.resolve(__dirname, '..', '..', 'src', 'utils', 'databaseEngine.js'), 'utf8',
);
const cli = fs.readFileSync(
path.resolve(__dirname, '..', '..', 'scripts', 'migrate-sqlite-to-postgres.js'), 'utf8',
);
for (const text of [src, cli]) {
expect(text).toContain("'inbound_documents'");
expect(text).not.toContain("'incoming_invoices'");
}
});
});
@@ -0,0 +1,161 @@
/**
* Regression tests for the feedback-settings write path (#1030).
*
* The admin event form posts its whole client-side feedback state back,
* including three keys that were never columns on event_feedback_settings:
* `enable_rate_limiting`, `rate_limit_window_minutes` and
* `rate_limit_max_requests`. Spreading those into the knex UPDATE threw,
* the route answered 500, and EventDetailsPage swallowed it — so the admin
* saw "Event updated successfully" while "Enable feedback" never persisted
* and guests could not leave any feedback.
*
* Pinned here:
* - UI-only keys are dropped, not written, on BOTH the insert (no row yet)
* and update (row exists) branches.
* - Every real column still round-trips.
* - Identity columns can't be mass-assigned through the settings body.
* - gallery.js no longer declares a duplicate GET /:slug/feedback-settings.
* server.js mounts galleryRoutes before galleryFeedback, so the duplicate
* shadowed the real handler and dropped the #655 per-guest caps from the
* guest payload.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-feedback-settings-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'feedback-settings-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const feedbackService = require('../../src/services/feedbackService');
// Exactly what EventDetailsPage holds in state before its settings GET
// resolves — the three rate-limit keys are UI-only.
const ADMIN_FORM_BODY = {
feedback_enabled: true,
allow_ratings: true,
allow_likes: true,
allow_comments: true,
allow_favorites: true,
allow_reactions: true,
require_name_email: false,
moderate_comments: true,
show_feedback_to_guests: true,
enable_rate_limiting: false,
rate_limit_window_minutes: 15,
rate_limit_max_requests: 10,
};
let db;
let cleanup;
let eventId;
async function insertEvent(slug) {
const inserted = await db('events').insert({
slug,
event_type: 'wedding',
event_name: 'Feedback Settings Test',
event_date: '2026-06-22',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${slug}/share`,
share_token: `${slug}-share`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return inserted[0]?.id ?? inserted[0];
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
eventId = await insertEvent('feedback-settings-test');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('updateEventFeedbackSettings ignores UI-only keys (#1030)', () => {
test('insert branch: enabling feedback on an event with no settings row persists', async () => {
const freshEventId = await insertEvent('feedback-settings-fresh');
const result = await feedbackService.updateEventFeedbackSettings(freshEventId, ADMIN_FORM_BODY);
expect(result.feedback_enabled).toBeTruthy();
const row = await db('event_feedback_settings').where('event_id', freshEventId).first();
expect(row).toBeTruthy();
expect(row.feedback_enabled).toBeTruthy();
expect(row).not.toHaveProperty('enable_rate_limiting');
});
test('update branch: flipping the toggle on an existing row persists', async () => {
await feedbackService.updateEventFeedbackSettings(eventId, { feedback_enabled: false });
expect((await feedbackService.getEventFeedbackSettings(eventId)).feedback_enabled).toBeFalsy();
const result = await feedbackService.updateEventFeedbackSettings(eventId, ADMIN_FORM_BODY);
expect(result.feedback_enabled).toBeTruthy();
const rows = await db('event_feedback_settings').where('event_id', eventId);
expect(rows).toHaveLength(1);
expect(rows[0].feedback_enabled).toBeTruthy();
});
test('every real column round-trips', async () => {
const result = await feedbackService.updateEventFeedbackSettings(eventId, {
...ADMIN_FORM_BODY,
allow_comments: false,
show_feedback_to_guests: false,
identity_mode: 'guest',
max_favorites_per_guest: 10,
max_likes_per_guest: 5,
});
expect(result.allow_comments).toBeFalsy();
expect(result.show_feedback_to_guests).toBeFalsy();
expect(result.identity_mode).toBe('guest');
expect(result.max_favorites_per_guest).toBe(10);
expect(result.max_likes_per_guest).toBe(5);
});
test('identity columns cannot be mass-assigned through the settings body', async () => {
const otherEventId = await insertEvent('feedback-settings-other');
const before = await db('event_feedback_settings').where('event_id', eventId).first();
await feedbackService.updateEventFeedbackSettings(eventId, {
feedback_enabled: true,
id: 99999,
event_id: otherEventId,
});
const after = await db('event_feedback_settings').where('event_id', eventId).first();
expect(after.id).toBe(before.id);
expect(after.event_id).toBe(eventId);
expect(await db('event_feedback_settings').where('event_id', otherEventId).first()).toBeUndefined();
});
});
describe('guest feedback-settings route is not shadowed (#1030)', () => {
test('gallery.js does not declare GET /:slug/feedback-settings', () => {
const source = fs.readFileSync(
path.resolve(__dirname, '..', '..', 'src', 'routes', 'gallery.js'), 'utf8',
);
expect(source).not.toMatch(/router\.get\(\s*['"]\/:slug\/feedback-settings['"]/);
});
test('galleryFeedback.js still serves it, including the #655 per-guest caps', () => {
const source = fs.readFileSync(
path.resolve(__dirname, '..', '..', 'src', 'routes', 'galleryFeedback.js'), 'utf8',
);
expect(source).toMatch(/router\.get\(\s*['"]\/:slug\/feedback-settings['"]/);
expect(source).toMatch(/max_favorites_per_guest/);
expect(source).toMatch(/max_likes_per_guest/);
});
});
@@ -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,106 @@
/**
* Regression test for clearing an event's expiration on SQLite (#1029).
*
* Migration 061 dropped the NOT NULL on events.event_date / events.expires_at
* for Postgres only — it skipped SQLite on the (wrong) premise that SQLite
* doesn't enforce NOT NULL. It does, so every SQLite install answered
*
* SQLITE_CONSTRAINT: NOT NULL constraint failed: events.expires_at
*
* when an admin cleared the expiration, surfacing as "Failed to update event".
* Migration 174 finishes the job. The harness runs on SQLite, so this asserts
* the real engine behaviour rather than a mock.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-nullable-dates-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'nullable-dates-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
let db;
let cleanup;
let eventId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: 'nullable-dates-test',
event_type: 'wedding',
event_name: 'Nullable Dates Test',
event_date: '2026-06-22',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: '/gallery/nullable-dates-test/share',
share_token: 'nullable-dates-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];
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('events date columns are nullable on SQLite (#1029)', () => {
test('the engine under test really is SQLite', () => {
expect(['sqlite3', 'better-sqlite3']).toContain(db.client.config.client);
});
test('clearing expires_at succeeds — this threw SQLITE_CONSTRAINT before migration 174', async () => {
await db('events').where('id', eventId).update({ expires_at: null });
const row = await db('events').where('id', eventId).first('expires_at');
expect(row.expires_at).toBeNull();
});
test('clearing event_date succeeds too (061 covered both columns on PG)', async () => {
await db('events').where('id', eventId).update({ event_date: null });
const row = await db('events').where('id', eventId).first('event_date');
expect(row.event_date).toBeNull();
});
test('a gallery can be created with no expiration at all', async () => {
const inserted = await db('events').insert({
slug: 'never-expires-test',
event_type: 'other',
event_name: 'Never Expires',
event_date: null,
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: '/gallery/never-expires-test/share',
share_token: 'never-expires-share',
expires_at: null,
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
const id = inserted[0]?.id ?? inserted[0];
const row = await db('events').where('id', id).first('expires_at', 'event_date');
expect(row.expires_at).toBeNull();
expect(row.event_date).toBeNull();
});
test('columns the events table depends on survived the table rebuild', async () => {
// Knex implements .alter() on SQLite by recreating the table; make sure the
// rebuild kept the row and the wider schema intact.
const row = await db('events').where('id', eventId).first();
expect(row.slug).toBe('nullable-dates-test');
expect(row.share_token).toBe('nullable-dates-share');
expect(await db.schema.hasColumn('events', 'allow_downloads')).toBe(true);
expect(await db.schema.hasColumn('events', 'hero_photo_id')).toBe(true);
const photos = await db('photos').where('event_id', eventId);
expect(Array.isArray(photos)).toBe(true);
});
});
+9 -46
View File
@@ -1,39 +1,13 @@
require('dotenv').config();
const path = require('path');
// Database configuration for different environments
const resolveSqliteFilename = (filenameEnv) => {
const fallback = path.join(__dirname, './data/photo_sharing.db');
if (!filenameEnv) {
return fallback;
}
const trimmed = String(filenameEnv).trim();
if (!trimmed) {
return fallback;
}
let resolved;
if (path.isAbsolute(trimmed)) {
resolved = trimmed;
} else if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
resolved = path.resolve(__dirname, trimmed);
} else {
resolved = path.join(__dirname, trimmed);
}
const normalized = path.normalize(resolved);
const baseSuffix = path.relative(path.parse(__dirname).root, path.normalize(__dirname));
const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`;
if (normalized.includes(duplicatePattern)) {
return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`);
}
return normalized;
};
// Shared with the engine guard (#1038) so both resolve the identical path.
const { resolveSqliteFilename } = require('./src/utils/sqlitePath');
// One resolution of the PostgreSQL target for the whole application (#1038).
// The development and production blocks used to carry different host/user/
// database defaults, so a process that probed or migrated against one could
// hand over to a process that opened another.
const { pgConnectionFromEnv } = require('./src/utils/pgConnection');
const sqliteConnection = (filenameEnv) => ({
filename: resolveSqliteFilename(filenameEnv)
@@ -54,13 +28,7 @@ const baseSqliteConfig = {
const config = {
development: {
client: process.env.DATABASE_CLIENT || 'sqlite3',
connection: process.env.DATABASE_CLIENT === 'pg' ? {
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'photo_sharing'
} : {
connection: process.env.DATABASE_CLIENT === 'pg' ? pgConnectionFromEnv() : {
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
},
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
@@ -97,12 +65,7 @@ const config = {
// Support both Postgres and SQLite in production based on DATABASE_CLIENT
connection: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
? {
host: process.env.DB_HOST || 'db',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'picpeak',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'picpeak',
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
...pgConnectionFromEnv(),
// Connection stability settings
connectionTimeoutMillis: 30000,
idleTimeoutMillis: 30000,
@@ -0,0 +1,42 @@
/**
* Migration 174: make events.event_date / events.expires_at nullable on SQLite (#1029).
*
* Migration 061 introduced the `event_require_event_date` /
* `event_require_expiration` settings and dropped the NOT NULL on both columns
* — but only for Postgres. It skipped SQLite on the premise that "SQLite
* doesn't enforce NOT NULL as strictly", which is simply untrue: clearing the
* expiration on a SQLite install fails with
*
* SQLITE_CONSTRAINT: NOT NULL constraint failed: events.expires_at
*
* so "never expires" has never been reachable there. This finishes 061 for
* SQLite. Knex implements .alter() on SQLite by recreating the table; migration
* 073 already does exactly that on `events`, so the path is well-trodden here.
*
* Postgres is skipped — 061 already handled it, and knex's .alter() rewrites
* the whole column definition (type, default, nullability), which would be a
* needless rewrite of a column that is already correct.
*/
function isSqlite(knex) {
const client = knex.client.config.client;
return client === 'sqlite3' || client === 'better-sqlite3';
}
exports.up = async function(knex) {
if (!isSqlite(knex)) return;
const hasEvents = await knex.schema.hasTable('events');
if (!hasEvents) return;
await knex.schema.alterTable('events', (table) => {
table.datetime('event_date').nullable().alter();
table.datetime('expires_at').nullable().alter();
});
};
exports.down = async function(knex) {
// Deliberately irreversible. Restoring NOT NULL would fail on any install
// that has since created a gallery without an expiration — exactly what this
// migration enables — and 061's down() takes the same position for Postgres.
};
+40
View File
@@ -276,11 +276,51 @@ async function runMigrations() {
}
// Add delay for database readiness in production
// Engine consistency check (#1038). The entrypoint resolves the engine before
// migrations run and exports DATABASE_CLIENT, so this normally agrees and does
// nothing. It bites on a MANUAL migration run: without that env, an install
// that is really on SQLite would resolve to Postgres here and build a schema in
// the empty database, which then hides the SQLite data from the boot-time
// check. Stop instead, and say which env to set.
async function assertEngine() {
const knexConfig = require('../knexfile');
const logger = require('../src/utils/logger');
const { resolveBootEngine } = require('../src/utils/databaseEngine');
const decision = await resolveBootEngine({ knexConfig, logger });
if (decision.reason === 'marker-target-mismatch') {
console.error(
'Refusing to migrate: this install was migrated to a different PostgreSQL than the\n'
+ 'one currently configured. The resolver printed both targets above.'
);
process.exit(1);
}
if (decision.reason === 'ambiguous-both-populated') {
// Both databases hold data and nothing records which is current; the
// resolver has already printed the comparison. There is no client to
// recommend here — the operator has to pick one.
console.error(
'Refusing to migrate: SQLite and PostgreSQL both hold data and neither is marked\n'
+ 'as current. Set DATABASE_CLIENT=pg or DATABASE_CLIENT=sqlite3 to say which one\n'
+ 'this command should touch.'
);
process.exit(1);
}
if (decision.client !== knexConfig.client) {
console.error(
`Refusing to migrate ${knexConfig.client} — this install's data is in ${decision.client}.\n`
+ `Run migrations through the container entrypoint, or set DATABASE_CLIENT=${decision.client} explicitly.\n`
+ 'To move the data across instead: node scripts/migrate-sqlite-to-postgres.js'
);
process.exit(1);
}
}
async function waitAndRun() {
if (process.env.NODE_ENV === 'production') {
console.log('Waiting 2 seconds for database readiness...');
await new Promise(resolve => setTimeout(resolve, 2000));
}
await assertEngine();
await runMigrations();
}
+40
View File
@@ -46,10 +46,50 @@ async function runMigration(filepath) {
}
}
// Engine consistency check (#1038). The entrypoint resolves the engine before
// migrations run and exports DATABASE_CLIENT, so this normally agrees and does
// nothing. It bites on a MANUAL migration run: without that env, an install
// that is really on SQLite would resolve to Postgres here and build a schema in
// the empty database, which then hides the SQLite data from the boot-time
// check. Stop instead, and say which env to set.
async function assertEngine() {
const knexConfig = require('../knexfile');
const logger = require('../src/utils/logger');
const { resolveBootEngine } = require('../src/utils/databaseEngine');
const decision = await resolveBootEngine({ knexConfig, logger });
if (decision.reason === 'marker-target-mismatch') {
console.error(
'Refusing to migrate: this install was migrated to a different PostgreSQL than the\n'
+ 'one currently configured. The resolver printed both targets above.'
);
process.exit(1);
}
if (decision.reason === 'ambiguous-both-populated') {
// Both databases hold data and nothing records which is current; the
// resolver has already printed the comparison. There is no client to
// recommend here — the operator has to pick one.
console.error(
'Refusing to migrate: SQLite and PostgreSQL both hold data and neither is marked\n'
+ 'as current. Set DATABASE_CLIENT=pg or DATABASE_CLIENT=sqlite3 to say which one\n'
+ 'this command should touch.'
);
process.exit(1);
}
if (decision.client !== knexConfig.client) {
console.error(
`Refusing to migrate ${knexConfig.client} — this install's data is in ${decision.client}.\n`
+ `Run migrations through the container entrypoint, or set DATABASE_CLIENT=${decision.client} explicitly.\n`
+ 'To move the data across instead: node scripts/migrate-sqlite-to-postgres.js'
);
process.exit(1);
}
}
// Main migration runner
async function runMigrations() {
try {
console.log('Starting database migrations...');
await assertEngine();
// First run the init.js if it exists but only if migrations table doesn't exist
const tableExists = await db.schema.hasTable('migrations');
+32 -22
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "3.45.10",
"version": "3.46.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "3.45.10",
"version": "3.46.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -46,7 +46,7 @@
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
"postcss": "8.5.18",
"postcss": "8.5.23",
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "2.17.5",
@@ -4499,9 +4499,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -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"
@@ -7104,9 +7114,9 @@
}
},
"node_modules/ip-address": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz",
"integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==",
"license": "MIT",
"engines": {
"node": ">= 12"
@@ -7981,9 +7991,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz",
"integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==",
"funding": [
{
"type": "github",
@@ -9076,9 +9086,9 @@
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"funding": [
{
"type": "github",
@@ -10032,9 +10042,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.18",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.18.tgz",
"integrity": "sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==",
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"funding": [
{
"type": "opencollective",
@@ -10051,7 +10061,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.45.13",
"version": "3.46.2",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"engines": {
@@ -55,7 +55,7 @@
"pdf-lib": "^1.17.1",
"pdfkit": "^0.17.2",
"pg": "^8.16.3",
"postcss": "8.5.18",
"postcss": "8.5.23",
"qrcode": "^1.5.4",
"react-i18next": "^15.6.0",
"sanitize-html": "2.17.5",
@@ -85,14 +85,15 @@
"fast-xml-parser": ">=5.7.0",
"qs": ">=6.15.2",
"tar": ">=7.5.21",
"brace-expansion": ">=5.0.7",
"brace-expansion": ">=5.0.9",
"minimatch": ">=9.0.7",
"path-to-regexp": "0.1.13",
"lodash": ">=4.18.1",
"follow-redirects": ">=1.16.0",
"@tootallnate/once": ">=3.0.1",
"ip-address": ">=10.1.1",
"ip-address": ">=10.3.1",
"uuid": "^11.1.1",
"nodemailer": "^9.0.1"
"nodemailer": "^9.0.1",
"deepmerge-ts": ">=8.0.1"
}
}
@@ -0,0 +1,537 @@
#!/usr/bin/env node
'use strict';
/**
* Move an install's data from SQLite to PostgreSQL (#1038).
*
* node scripts/migrate-sqlite-to-postgres.js [--force] [--keep-archive]
*
* For installs that have been unknowingly running on SQLite: the image used to
* leave NODE_ENV unset, so knexfile.js fell back to its development block and
* ignored DB_HOST/DB_USER/DB_PASSWORD. Their galleries live in the SQLite file
* while the Postgres database they provisioned sits empty.
*
* This deliberately reuses the .picpeak export/import services rather than
* hand-rolling a cross-engine copy — they already solve the parts that are easy
* to get wrong: foreign-key suspension during the load, JSON column handling
* per engine, and (critically) resyncing Postgres serial sequences after rows
* are inserted with explicit ids.
*
* Both services bind to the global `db` at require time, so each half runs in
* its own child process with DATABASE_CLIENT pinned — this script re-invokes
* itself with --phase for that.
*
* Photos and other files on disk are NOT touched: only database rows move. The
* SQLite file is left exactly as it was, so the migration is reversible by
* unsetting DATABASE_CLIENT again.
*/
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawnSync } = require('child_process');
const BACKEND_ROOT = path.resolve(__dirname, '..');
// Same configuration sources the running backend uses. Without these, invoking
// this CLI directly (or via `docker exec`, which does not inherit the exports
// wait-for-db.sh performs) would fail the pre-flight checks below even though
// the child phases would happily read backend/.env through knexfile.
require('dotenv').config({ path: path.join(BACKEND_ROOT, '.env') });
for (const [varName, file] of [['DB_PASSWORD', 'db_password'], ['JWT_SECRET', 'jwt_secret']]) {
const secretFile = `/run/secrets/${file}`;
if (!process.env[varName] && fs.existsSync(secretFile)) {
try {
process.env[varName] = fs.readFileSync(secretFile, 'utf8').trim();
} catch (_) { /* unreadable secret — the checks below report it */ }
}
}
function parseArgs(argv) {
return {
force: argv.includes('--force'),
keepArchive: argv.includes('--keep-archive'),
phase: (argv.find((a) => a.startsWith('--phase=')) || '').split('=')[1] || null,
archive: (argv.find((a) => a.startsWith('--archive=')) || '').split('=')[1] || null,
resultFile: (argv.find((a) => a.startsWith('--result-file=')) || '').split('=')[1] || null,
ignoreBootstrapAdmins: argv.includes('--ignore-bootstrap-admins'),
};
}
// Resolve the Postgres target ONCE, with production defaults, and hand the same
// explicit values to every child. Otherwise the block knexfile happens to pick
// decides the database name, and the migration can land somewhere the running
// application will never open (#1038 review).
function normalisedPgEnv() {
const { pgConnectionFromEnv } = require('../src/utils/databaseEngine');
const c = pgConnectionFromEnv();
return {
DB_HOST: String(c.host),
DB_PORT: String(c.port),
DB_USER: String(c.user),
DB_NAME: String(c.database),
};
}
function runPhase(phase, client, extraArgs = []) {
// The child's stdout is NOT a private channel: winston logs to the console
// outside production and whenever LOG_TO_CONSOLE=true, so the payload comes
// back through a file instead.
const resultFile = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), `picpeak-phase-${phase}-`)), 'result',
);
try {
const res = spawnSync(
process.execPath,
[__filename, `--phase=${phase}`, `--result-file=${resultFile}`, ...extraArgs],
{
cwd: BACKEND_ROOT,
env: {
...process.env,
...normalisedPgEnv(),
DATABASE_CLIENT: client,
// Production semantics for the child regardless of how the CLI was
// invoked: the development block ignores DB_SSL, so a managed Postgres
// that requires TLS could not be migrated into at all.
NODE_ENV: 'production',
},
stdio: ['ignore', 'inherit', 'inherit'],
encoding: 'utf8',
},
);
if (res.status !== 0) {
throw new Error(`${phase} phase failed (exit ${res.status})`);
}
return fs.existsSync(resultFile) ? fs.readFileSync(resultFile, 'utf8').trim() : '';
} finally {
fs.rmSync(path.dirname(resultFile), { recursive: true, force: true });
}
}
// ── phases (each runs in its own process, with DATABASE_CLIENT pinned) ────────
async function phaseExport() {
const { createPicpeak } = require('../src/services/picpeakExportService');
const outDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sqlite-migration-'));
// Rows only. This moves an install between engines on the SAME machine, so
// every file is already where it belongs; hauling business docs through /tmp
// would just risk filling the temp disk.
try {
const { filePath } = await createPicpeak({ includePhotos: false, includeFiles: false, outDir });
return filePath;
} catch (err) {
// createPicpeak leaves a caller-supplied outDir alone on failure, and a
// partial archive still contains password hashes and credentials.
fs.rmSync(outDir, { recursive: true, force: true });
throw err;
}
}
// Tables that are EMPTY on a freshly migrated schema, so any row in them means
// a human has used this install. Used to protect the target from being wiped
// and to decide whether the source is worth migrating (#1038 review). Tables
// missing on a given branch are skipped.
const USER_DATA_TABLES = [
'events', 'photos', 'photo_feedback', 'admin_users', 'customer_accounts',
'quotes', 'invoices', 'projects', 'expenses', 'inbound_documents',
];
async function tablesWithData(db, tables, { ignoreBootstrapAdmins = false } = {}) {
const { adminsIndicateUse } = require('../src/utils/databaseEngine');
const found = {};
for (const table of tables) {
if (!(await db.schema.hasTable(table))) continue;
if (table === 'admin_users' && ignoreBootstrapAdmins) {
// Match probePgData: one never-used seeded admin is not "user data", or
// the migration would demand --force against an empty target.
const cols = ['must_change_password'];
if (await db.schema.hasColumn('admin_users', 'last_login')) cols.push('last_login');
const rows = await db('admin_users').select(cols);
if (adminsIndicateUse(rows)) found[table] = rows.length;
continue;
}
const row = await db(table).count('* as count').first();
const count = Number(row?.count || 0);
if (count > 0) found[table] = count;
}
return found;
}
async function phaseUserData(ignoreBootstrapAdmins) {
const { db } = require('../src/database/db');
return JSON.stringify(await tablesWithData(db, USER_DATA_TABLES, { ignoreBootstrapAdmins }));
}
// Fingerprint EVERY table the export carries, not a hand-picked few: writes to
// an unlisted table were invisible, and count+maxId alone misses in-place
// UPDATEs (an event edit, a password change). max(updated_at) covers those
// wherever the column exists. Still not a substitute for stopping the backend —
// a table with neither `id` nor `updated_at` can be edited unnoticed — which is
// why the script says so up front.
async function phaseFingerprint() {
const { db } = require('../src/database/db');
const { listDataTables } = require('../src/services/picpeakExportService');
const out = {};
for (const table of await listDataTables()) {
const entry = {};
try {
entry.count = Number((await db(table).count('* as count').first())?.count || 0);
} catch (_) {
continue; // table vanished mid-run; the export would fail on it anyway
}
for (const [key, col] of [['maxId', 'id'], ['maxUpdated', 'updated_at']]) {
try {
const row = await db(table).max(`${col} as v`).first();
if (row && row.v !== null && row.v !== undefined) entry[key] = String(row.v);
} catch (_) { /* column doesn't exist on this table */ }
}
out[table] = entry;
}
return JSON.stringify(out);
}
async function phaseMigrateSchema() {
// runMigrations() exits the process itself (0 on success, 1 on failure), so the
// child's exit code is the result — nothing to return.
const { runMigrations } = require('../migrations/run-migrations-safe');
await runMigrations();
}
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.
// 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 || {});
}
function summariseUserData(found) {
return Object.entries(found).map(([t, n]) => `${t}=${n}`).join(', ');
}
function describeDrift(before, after) {
const drifted = [];
for (const table of new Set([...Object.keys(before), ...Object.keys(after)])) {
const a = before[table] || {};
const b = after[table] || {};
if (a.count !== b.count) {
drifted.push(`${table}: ${a.count ?? 0} rows → ${b.count ?? 0}`);
} else if (a.maxId !== b.maxId || a.maxUpdated !== b.maxUpdated) {
drifted.push(`${table}: rows edited in place (max id ${a.maxId ?? '-'}${b.maxId ?? '-'}, `
+ `last update ${a.maxUpdated ?? '-'}${b.maxUpdated ?? '-'})`);
}
}
return drifted;
}
// Set once the export exists; every failure path clears it (the archive holds
// plaintext secrets, so leaving it behind on error is not acceptable).
let archiveToClean = null;
function cleanupArchive() {
if (!archiveToClean) return;
try {
fs.rmSync(path.dirname(archiveToClean), { recursive: true, force: true });
} catch (err) {
console.error(` WARNING: could not remove ${archiveToClean} (${err.message}) — it contains`
+ ' plaintext secrets, delete it by hand.');
}
archiveToClean = null;
}
// ── orchestration ────────────────────────────────────────────────────────────
async function main() {
const args = parseArgs(process.argv.slice(2));
// Child phase. The knex pool holds the event loop open, so finish by flushing
// stdout and exiting explicitly — otherwise the parent's spawnSync waits on a
// process that will never end by itself.
if (args.phase) {
const payload = args.phase === 'export' ? await phaseExport()
: args.phase === 'fingerprint' ? await phaseFingerprint()
: args.phase === 'user-data' ? await phaseUserData(args.ignoreBootstrapAdmins)
: args.phase === 'import' ? await phaseImport(args.archive)
: await phaseMigrateSchema();
if (args.resultFile) fs.writeFileSync(args.resultFile, String(payload ?? ''));
// The knex pool holds the event loop open; exit explicitly or the parent's
// spawnSync waits on a process that will never end by itself.
process.exit(0);
}
const { resolveSqlitePath } = require('../src/utils/databaseEngine');
const sqlitePath = resolveSqlitePath();
console.log('PicPeak — SQLite → PostgreSQL migration\n');
if (!fs.existsSync(sqlitePath)) {
console.error(`No SQLite database at ${sqlitePath}. Nothing to migrate.`);
process.exit(1);
}
if (process.env.DATABASE_CLIENT && process.env.DATABASE_CLIENT !== 'pg') {
console.error(
`This deployment pins DATABASE_CLIENT=${process.env.DATABASE_CLIENT}.\n`
+ 'After the migration the application must run on PostgreSQL — the SQLite file is\n'
+ 'renamed out of the way, so a restart with this setting would create a NEW, empty\n'
+ 'SQLite database and serve that instead of your data.\n\n'
+ 'Set DATABASE_CLIENT=pg (or remove it) in your deployment, then run this again.'
);
process.exit(1);
}
// Not a refusal: an unset NODE_ENV is exactly the state the affected installs
// are in, and refusing would block the people this script is for. The success
// marker makes the boot resolve to Postgres regardless; this just tells the
// operator to make it explicit.
if (!process.env.DATABASE_CLIENT && require('../knexfile').client !== 'pg') {
console.log(
'Note: this environment resolves to SQLite (NODE_ENV is not "production" and\n'
+ 'DATABASE_CLIENT is unset). The migration will still complete and the marker it\n'
+ 'writes makes the app use PostgreSQL afterwards, but set NODE_ENV=production (or\n'
+ 'DATABASE_CLIENT=pg) so the configuration says what is actually happening.\n'
);
}
if (!process.env.DB_HOST && !process.env.DB_PASSWORD) {
console.error(
'No PostgreSQL settings found (DB_HOST / DB_PASSWORD). Set them the way the\n'
+ 'backend does, then re-run this script inside the container.'
);
process.exit(1);
}
console.log(
'Stop the backend before running this. If it keeps serving while the copy runs,\n'
+ 'anything written after the export is left behind in SQLite and becomes invisible\n'
+ 'once the engine switches. This script checks for that afterwards and fails loudly,\n'
+ 'but stopping the container first is the only way to be sure.\n'
);
const sourceData = JSON.parse(runPhase('user-data', 'sqlite3'));
console.log(` source : ${sqlitePath}${summariseUserData(sourceData) || 'no user data'}`);
if (!Object.keys(sourceData).length) {
console.error(
'\nThe SQLite database holds no user data at all (no galleries, admins, customers or\n'
+ 'accounting records). There is nothing to migrate.'
);
process.exit(1);
}
const sqliteBefore = JSON.parse(runPhase('fingerprint', 'sqlite3'));
// Read the target BEFORE creating the schema: migration 001 seeds a bootstrap
// admin when ADMIN_PASSWORD is set (common on legacy installs), and counting
// that as "user data" would refuse a migration into a genuinely empty
// database — pushing the operator towards --force for no reason.
const { hasMigrationInProgress, migrationInProgressPath } = require('../src/utils/databaseEngine');
// The retry allowance is bound to the TARGET, not just to this SQLite file:
// if the operator repointed DB_HOST/DB_NAME since the failed attempt, the
// rows in front of us belong to some other database and must not be replaced
// without an explicit --force.
const pgEnv = normalisedPgEnv();
const targetId = `${pgEnv.DB_HOST}:${pgEnv.DB_PORT}/${pgEnv.DB_NAME}`;
let retryingOwnRun = false;
if (hasMigrationInProgress(sqlitePath)) {
try {
const pin = JSON.parse(fs.readFileSync(migrationInProgressPath(sqlitePath), 'utf8'));
retryingOwnRun = pin.target === targetId;
if (!retryingOwnRun) {
console.log(` (an earlier attempt targeted ${pin.target}; this run targets ${targetId})`);
}
} catch (_) {
retryingOwnRun = false; // unreadable pin — treat as unknown, require --force
}
}
const targetData = JSON.parse(runPhase('user-data', 'pg', ['--ignore-bootstrap-admins']));
console.log(` target : postgres — ${summariseUserData(targetData) || 'empty'}`);
if (retryingOwnRun && Object.keys(targetData).length) {
// Whatever is in Postgres came from a previous attempt of THIS script that
// never completed — re-running is the documented recovery, so don't make
// the operator reach for a destructive-sounding flag to do it.
console.log(' (an earlier migration did not finish; re-running replaces what it left behind)');
} else if (Object.keys(targetData).length && !args.force) {
console.error(
`\nPostgreSQL already holds user data (${summariseUserData(targetData)}).\n`
+ 'The import REPLACES every table, so this would delete it — including admins,\n'
+ 'customers and accounting records that have no galleries attached.\n'
+ 'Re-run with --force only if you are certain you want that data gone.'
);
process.exit(1);
}
// Pin the boot to SQLite for the duration. Everything below writes to
// Postgres — schema creation alone seeds a bootstrap admin when
// ADMIN_PASSWORD is set — and a run that dies half way would otherwise leave
// Postgres looking occupied enough for the next restart to switch to it.
const inProgress = migrationInProgressPath(sqlitePath);
fs.writeFileSync(inProgress, JSON.stringify({
started_at: new Date().toISOString(),
target: targetId,
}, null, 2));
// Now build the schema — the import replaces table CONTENTS, it never creates
// them, and a fresh database has no tables at all.
//
// core/001_init.js writes data/ADMIN_CREDENTIALS.txt when ADMIN_PASSWORD is
// set, and that data directory belongs to the SOURCE install — so bootstrapping
// the schema would replace the operator's real credentials file with ones for
// a temporary admin the import then discards. Preserve it across the phase.
const credFile = path.join(BACKEND_ROOT, 'data', 'ADMIN_CREDENTIALS.txt');
const credBefore = fs.existsSync(credFile) ? fs.readFileSync(credFile) : null;
console.log('\n Preparing PostgreSQL schema…');
try {
runPhase('migrate-schema', 'pg');
} finally {
if (credBefore !== null) fs.writeFileSync(credFile, credBefore);
else fs.rmSync(credFile, { force: true });
}
console.log('\n Exporting rows from SQLite…');
const archive = runPhase('export', 'sqlite3');
// From here on, every exit path must remove the archive: it holds password
// hashes, SMTP credentials and API keys in plaintext.
archiveToClean = args.keepArchive ? null : archive;
const sizeMb = (fs.statSync(archive).size / 1024 / 1024).toFixed(1);
console.log(` archive: ${archive} (${sizeMb} MB)`);
// Check BEFORE touching Postgres: if the backend wrote to SQLite while the
// export ran, the snapshot is already incomplete and there is no reason to
// load it. Bailing here leaves Postgres exactly as it was.
const driftDuringExport = describeDrift(sqliteBefore, JSON.parse(runPhase('fingerprint', 'sqlite3')));
if (driftDuringExport.length) {
console.error(
'\nSQLite CHANGED WHILE THE EXPORT RAN — the backend is still writing to it:\n'
+ driftDuringExport.map((d) => ` ${d}`).join('\n')
+ '\n\nNothing was loaded into Postgres, and this install stays pinned to SQLite\n'
+ 'until a run completes. Stop the backend and run this again.'
);
process.exit(1);
}
console.log('\n Loading into PostgreSQL…');
runPhase('import', 'pg', [`--archive=${archive}`]);
// And again afterwards: writes can also land while the load runs, and those
// rows would vanish from view the moment the engine switches.
const driftDuringImport = describeDrift(sqliteBefore, JSON.parse(runPhase('fingerprint', 'sqlite3')));
if (driftDuringImport.length) {
console.error(
'\nSQLite CHANGED WHILE THE IMPORT RAN — the backend is still writing to it:\n'
+ driftDuringImport.map((d) => ` ${d}`).join('\n')
+ '\n\nPostgres now holds an incomplete copy. Your SQLite data is intact and stays\n'
+ 'the one being served — the boot is pinned to it until a run completes. Stop the\n'
+ 'backend and run this again; the import replaces every table, so re-running is safe.'
);
process.exit(1);
}
// Row-for-row comparison of the whole database, not just galleries: every
// table the export carried must have arrived with the same row count.
const targetAfter = JSON.parse(runPhase('fingerprint', 'pg'));
// Only a SHORTFALL is a problem. The import legitimately adds rows of its own
// afterwards — setSessionsValidAfter() writes an app_settings row so tokens
// minted before the restore stop authenticating — and a target that gained
// rows has not lost anything.
const missing = [];
const gained = [];
const skipped = [];
for (const [table, src] of Object.entries(sqliteBefore)) {
const dst = targetAfter[table];
if (!dst) {
// SQLite-only tables exist: initializeDatabase() builds an `events_new`
// scratch table and, if its legacy copy throws, the catch leaves the empty
// table behind (db.js). The importer correctly skips tables Postgres does
// not have — so an ABSENT table only matters if it actually held rows.
// Flagging empty ones failed the whole migration after the data had
// already landed, leaving the install pinned to SQLite forever.
if (src.count > 0) missing.push(`${table}: ${src.count} rows, no such table in Postgres`);
else skipped.push(table);
continue;
}
if (dst.count < src.count) missing.push(`${table}: ${src.count} rows → ${dst.count}`);
else if (dst.count > src.count) gained.push(`${table}: ${src.count}${dst.count}`);
}
if (skipped.length) {
console.log(` (empty SQLite-only tables with no Postgres counterpart, skipped: ${skipped.join(', ')})`);
}
if (gained.length) console.log(` (rows added by the import itself: ${gained.join(', ')})`);
console.log(`\n PostgreSQL now holds ${summariseUserData(JSON.parse(runPhase('user-data', 'pg')))}.`);
if (missing.length) {
console.error(
'\nROW COUNTS DO NOT MATCH — Postgres did not receive everything:\n'
+ missing.map((m) => ` ${m}`).join('\n')
+ '\n\nYour SQLite data is untouched and stays the one being served — the boot is\n'
+ 'pinned to it until a run completes. Report this with the list above.'
);
process.exit(1);
}
// Pin the engine choice so a later "Postgres looks empty" moment can never
// send the install back to this now-stale file.
const { migrationMarkerPath } = require('../src/utils/databaseEngine');
const marker = migrationMarkerPath(sqlitePath);
const retired = `${sqlitePath}.pre-postgres-${new Date().toISOString().replace(/[:.]/g, '-')}`;
// Marker FIRST, rename second. The other order has a window where a failure
// (a full disk, say) leaves the source renamed away with no success marker:
// the next run reports "No SQLite database", the in-progress pin is still
// there, and the operator never sees the rollback path. Writing the marker
// first means a failure here leaves everything exactly where it was.
fs.writeFileSync(marker, JSON.stringify({
migrated_at: new Date().toISOString(),
retired_sqlite_file: null,
target: targetId,
}, null, 2));
let retiredTo = null;
try {
fs.renameSync(sqlitePath, retired);
retiredTo = retired;
fs.writeFileSync(marker, JSON.stringify({
migrated_at: new Date().toISOString(),
retired_sqlite_file: retiredTo,
target: targetId,
}, null, 2));
} catch (err) {
// The marker already pins the engine to Postgres, so leaving the file in
// place is safe — it just is not renamed out of the way.
console.log(` (could not rename the SQLite file: ${err.message} — leaving it in place)`);
}
// Success — release the pin. Order matters: the success marker exists before
// the pin is dropped, so no restart in between can pick the wrong engine.
fs.rmSync(inProgress, { force: true });
if (args.keepArchive) {
console.log(` archive kept at ${archive} — it contains plaintext secrets, delete it when done`);
} else {
cleanupArchive();
}
console.log(`
Done. Your data is now in PostgreSQL.
rollback copy : ${retiredTo || sqlitePath}
marker : ${marker}
Restart the container to pick up PostgreSQL. Keep the rollback copy until you
have confirmed the galleries look right.
To roll back, all three steps are needed — with data on both sides the boot
picks PostgreSQL, so restoring the file alone changes nothing:
1. rm ${marker}
2. mv ${retiredTo || sqlitePath} ${sqlitePath}
3. set DATABASE_CLIENT=sqlite3 in your deployment
`);
}
process.on('exit', cleanupArchive);
main().catch((err) => {
console.error(`\nMigration failed: ${err.message}`);
console.error('Nothing was changed in SQLite; your data is still there.');
process.exit(1);
});
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env node
'use strict';
/**
* Prints the database client this boot should use — `pg` or `sqlite3` — for
* wait-for-db.sh to export as DATABASE_CLIENT (#1038).
*
* Runs BEFORE the migration step on purpose: the decision has to be made while
* the Postgres target is still untouched, so an install that has been
* unknowingly running on SQLite keeps serving from its SQLite file instead of
* coming up against an empty database.
*
* stdout is the client and nothing else — the caller captures it. Everything
* human-readable goes to stderr so it lands in the container log.
*/
const knexConfig = require('../knexfile');
// Must cover every level resolveBootEngine uses. An incomplete shim threw
// inside the conflict path, was swallowed by the catch below, and fell back to
// the configured client — silently choosing the engine this is meant to refuse
// to choose.
const logger = {
info: (m) => process.stderr.write(`${m}\n`),
warn: (m) => process.stderr.write(`${m}\n`),
error: (m) => process.stderr.write(`${m}\n`),
debug: () => {},
};
// Distinct exit code for "two populated databases, no record of which is
// current" (#1038). Callers must stop rather than pick one.
const CONFLICT_EXIT = 3;
(async () => {
let client = knexConfig.client;
try {
const { resolveBootEngine } = require('../src/utils/databaseEngine');
const decision = await resolveBootEngine({ knexConfig, logger });
if (decision.reason === 'ambiguous-both-populated'
|| decision.reason === 'marker-target-mismatch') {
process.exit(CONFLICT_EXIT);
}
({ client } = decision);
} catch (err) {
// Never let engine detection stop a boot: fall back to whatever knexfile
// resolved, which is exactly the behaviour before this script existed.
logger.warn(`Database engine detection failed (${err.message}); using ${client}`);
}
process.stdout.write(String(client || ''));
process.exit(0);
})();
+12 -13
View File
@@ -14,17 +14,14 @@ const bcrypt = require('bcrypt');
const path = require('path');
require('dotenv').config({ path: path.join(__dirname, '../.env') });
const knex = require('knex');
const db = knex({
client: process.env.DB_CLIENT || 'pg',
connection: {
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'picpeak',
password: process.env.DB_PASSWORD || 'picpeak',
database: process.env.DB_NAME || 'picpeak_dev'
}
});
// Use the application's own connection, like every sibling script here
// (reset-admin-password, create-admin, show-admin-credentials, reset-admin-mfa).
// This file used to hand-roll its own knex config, which meant: it read
// DB_CLIENT — a variable nothing else in the codebase sets — and so defaulted
// to Postgres on SQLite installs; and it defaulted to database `picpeak_dev`,
// a name no other component uses. Setting a password could therefore silently
// target a different database than the one the application serves (#1038).
const { db } = require('../src/database/db');
/**
* Validate password strength
@@ -111,8 +108,10 @@ async function setAdminPassword() {
.where('username', 'admin')
.update({
password_hash: hashedPassword,
password_changed_at: new Date(),
updated_at: new Date()
// ISO strings, not Date objects — they round-trip on both engines, and
// this script now runs on SQLite installs too.
password_changed_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
if (updated === 0) {
+50
View File
@@ -4,11 +4,61 @@ require('dotenv').config();
const { validateEnvironment } = require('./src/config/validateEnv');
validateEnvironment();
// Resolve which database engine this process should use, BEFORE anything
// requires knexfile/db (#1038). wait-for-db.sh normally does this and exports
// DATABASE_CLIENT, but a Kubernetes manifest that sets `command`/`args`, or a
// plain `docker run … node server.js`, bypasses the entrypoint entirely — and
// those are exactly the deployments this fix is for. Without this, such an
// install would resolve to Postgres (NODE_ENV is baked into the image now) and
// come up against an empty database while its SQLite data sat there unseen.
//
// spawnSync because the decision needs an async Postgres probe and this must
// happen before the first `require` of knexfile. It short-circuits without
// probing when DATABASE_CLIENT is already set, so the entrypoint path pays
// nothing.
// Also run it when a migration pin exists: an explicit DATABASE_CLIENT=pg
// would otherwise skip the check and start against a half-migrated Postgres
// while SQLite is still the database of record.
if (!process.env.DATABASE_CLIENT
|| require('./src/utils/databaseEngine').hasMigrationInProgress()) {
const { spawnSync } = require('child_process');
const probe = spawnSync(
process.execPath,
[require('path').join(__dirname, 'scripts', 'resolve-db-engine.js')],
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] }
);
// Exit 3: two populated databases and no record of which is authoritative.
// The resolver has printed the comparison and the two ways to resolve it;
// starting either engine would hide the other's data.
if (probe.status === 3) {
process.exit(1);
}
const resolved = (probe.stdout || '').trim();
if (probe.status === 0 && resolved) {
process.env.DATABASE_CLIENT = resolved;
// Pin the CONNECTION too, not just the client. knexfile's development block
// defaults Postgres to localhost/postgres/photo_sharing and production to
// db/picpeak/picpeak, so naming only the client can point this process at a
// different database than the resolver probed — with SQLite already retired.
if (resolved === 'pg') {
const conn = require('./src/utils/databaseEngine').pgConnectionFromEnv();
process.env.DB_HOST = String(conn.host);
process.env.DB_PORT = String(conn.port);
process.env.DB_USER = String(conn.user);
process.env.DB_NAME = String(conn.database);
}
}
}
// Initialize logger early to capture startup logs
const logger = require('./src/utils/logger');
logger.info('Server starting up', {
nodeVersion: process.version,
environment: process.env.NODE_ENV || 'development',
// Which database this process actually talks to (#1038). Nothing logged this
// before, so an install silently running on SQLite with Postgres configured
// had no way to notice.
database: require('./src/utils/databaseEngine').describeEngine(require('./knexfile')),
timestamp: new Date().toISOString()
});
+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);
})
);
+12 -12
View File
@@ -7,6 +7,7 @@ const path = require('path');
const os = require('os');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
const { resolveSqlitePath } = require('../utils/databaseEngine');
const { checkForUpdates, getCurrentChannel, getCurrentVersion, getReleasesSince, compareVersions } = require('../services/updateCheckService');
const { getAppSetting, upsertAppSetting } = require('../utils/appSettings');
const { parseWhatsNew } = require('../utils/whatsNew');
@@ -218,26 +219,25 @@ router.get('/updates/instructions', adminAuth, requirePermission('settings.view'
// Get comprehensive system status
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Database size - check if PostgreSQL or SQLite
// Database size. Read the LIVE connection rather than re-deriving any of
// this from the environment (#1038): DATABASE_CLIENT is not the only thing
// that decides the engine, DB_NAME is not the only thing that decides the
// database, and DATABASE_PATH was ignored outright here — so a SQLite
// install with a custom path, or a Postgres install without an explicit
// DATABASE_CLIENT, reported the size of something it was not using.
let dbSize = 0;
const dbClient = process.env.DATABASE_CLIENT || 'sqlite3';
if (dbClient === 'pg') {
// PostgreSQL - query database size
const liveConnection = db.client.config.connection || {};
if (db.client.config.client === 'pg') {
try {
const dbName = process.env.DB_NAME || 'picpeak';
const result = await db.raw(`
SELECT pg_database_size(?) as size
`, [dbName]);
const result = await db.raw('SELECT pg_database_size(current_database()) as size');
dbSize = result.rows[0]?.size || 0;
} catch (error) {
logger.error('Error getting PostgreSQL database size:', error);
}
} else {
// SQLite - check file size
const dbPath = path.join(__dirname, '../../data/photo_sharing.db');
try {
const stats = await fs.stat(dbPath);
const stats = await fs.stat(liveConnection.filename || resolveSqlitePath());
dbSize = stats.size;
} catch (error) {
logger.error('Error getting SQLite database size:', error);
+7 -1
View File
@@ -201,7 +201,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() {
+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');
+38 -35
View File
@@ -2,6 +2,11 @@ const express = require('express');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
// SQLite stores booleans as 0/1, Postgres as true/false (#1028). Strict
// comparisons against `true`/`false` therefore read every flag backwards on
// SQLite — parseBooleanInput normalises both engines and takes the per-column
// default for legacy NULL rows.
const { parseBooleanInput } = require('../utils/parsers');
const { getAppSetting } = require('../utils/appSettings');
const archiver = require('archiver');
const path = require('path');
@@ -534,7 +539,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// Check if feedback should be visible to guests
const feedbackService = require('../services/feedbackService');
const feedbackSettings = await feedbackService.getEventFeedbackSettings(req.event.id);
const showFeedbackToGuests = isClient || feedbackSettings.show_feedback_to_guests !== false;
const showFeedbackToGuests = isClient || parseBooleanInput(feedbackSettings.show_feedback_to_guests, true);
// Then get comment counts separately
const commentCounts = await db('photo_feedback')
@@ -599,7 +604,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// Per-category download flag (#640). false explicitly disables; the
// gallery hides the download button. Defaults true so categories
// created before migration 135 keep working.
allow_downloads: cat.allow_downloads !== false
allow_downloads: parseBooleanInput(cat.allow_downloads, true)
}));
}
@@ -626,9 +631,9 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
const protectionSettings = {
protection_level: req.event.protection_level || 'standard',
image_quality: req.event.image_quality || 85,
use_canvas_rendering: req.event.use_canvas_rendering === true,
use_canvas_rendering: parseBooleanInput(req.event.use_canvas_rendering, false),
fragmentation_level: req.event.fragmentation_level || 3,
overlay_protection: req.event.overlay_protection !== false
overlay_protection: parseBooleanInput(req.event.overlay_protection, true)
};
// Lightbox preview tier (#492). When the admin opts in, the
@@ -675,13 +680,15 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
color_theme: req.event.color_theme,
expires_at: req.event.expires_at,
hero_photo_id: req.event.hero_photo_id,
allow_downloads: req.event.allow_downloads !== false,
allow_user_uploads: req.event.allow_user_uploads === true,
disable_right_click: req.event.disable_right_click === true,
watermark_downloads: req.event.watermark_downloads === true,
// Defaults match /info: downloads on unless explicitly disabled,
// uploads off unless explicitly enabled (#1028).
allow_downloads: parseBooleanInput(req.event.allow_downloads, true),
allow_user_uploads: parseBooleanInput(req.event.allow_user_uploads, false),
disable_right_click: parseBooleanInput(req.event.disable_right_click, false),
watermark_downloads: parseBooleanInput(req.event.watermark_downloads, false),
watermark_text: req.event.watermark_text,
enable_devtools_protection: req.event.enable_devtools_protection === true,
use_canvas_rendering: req.event.use_canvas_rendering === true,
enable_devtools_protection: parseBooleanInput(req.event.enable_devtools_protection, false),
use_canvas_rendering: parseBooleanInput(req.event.use_canvas_rendering, false),
hero_logo_visible: resolveHeroLogoVisible(req.event.hero_logo_visible, globalHeroLogoVisible),
hero_logo_size: req.event.hero_logo_size || globalLogoSize || 'medium',
hero_logo_position: req.event.hero_logo_position || 'top',
@@ -726,6 +733,17 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
: null,
// Slideshow source (#1015). Same preview tier, but emitted
// unconditionally: the slideshow has no `url` fallback worth
// taking (originals are projector-sized) and must never land on
// `hero_url`, which is cover-cropped to 16:9 — that made the
// "no crop" fit letterbox an already-cropped frame. The preview
// route generates lazily and redirects to the original on any
// failure, so this is safe even where no preview exists yet.
slideshow_url: photo.media_type !== 'video'
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
: null,
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type,
@@ -734,7 +752,7 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
// Per-category download permission (#640). Defaults true for photos
// without a category or for categories that pre-date migration 135.
category_allow_downloads: photo.category_id && categoryMap[photo.category_id]
? categoryMap[photo.category_id].allow_downloads !== false
? parseBooleanInput(categoryMap[photo.category_id].allow_downloads, true)
: true,
category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null,
size: photo.size_bytes,
@@ -844,7 +862,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
const { photoId } = req.params;
// Check if downloads are allowed for this event
if (req.event.allow_downloads === false) {
if (!parseBooleanInput(req.event.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
@@ -868,7 +886,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
const cat = await db('photo_categories')
.where('id', photo.category_id)
.first('allow_downloads');
if (cat && cat.allow_downloads === false) {
if (cat && !parseBooleanInput(cat.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this category' });
}
}
@@ -972,7 +990,7 @@ async function bumpEventDownloadCounts(eventId) {
router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
try {
// Check if downloads are allowed for this event
if (req.event.allow_downloads === false) {
if (!parseBooleanInput(req.event.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
@@ -1192,7 +1210,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken, async (req, res) => {
try {
// Check if downloads are allowed for this event
if (req.event.allow_downloads === false) {
if (!parseBooleanInput(req.event.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
@@ -1872,26 +1890,11 @@ router.get('/:slug/preview/:photoId',
}
);
// Get feedback settings for gallery
router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => {
try {
const feedbackService = require('../services/feedbackService');
const settings = await feedbackService.getEventFeedbackSettings(req.event.id);
res.json({
feedback_enabled: settings.feedback_enabled || false,
allow_ratings: settings.allow_ratings,
allow_likes: settings.allow_likes,
allow_comments: settings.allow_comments,
allow_favorites: settings.allow_favorites,
show_feedback_to_guests: settings.show_feedback_to_guests,
require_name_email: settings.require_name_email || false,
identity_mode: settings.identity_mode || 'simple'
});
} catch (error) {
errorResponse(res, error, 500, 'Failed to fetch feedback settings');
}
});
// GET /:slug/feedback-settings lives in galleryFeedback.js. A duplicate of it
// used to sit here, and since server.js mounts galleryRoutes before
// galleryFeedback it shadowed the real handler — dropping the per-guest caps
// (#655) from the guest payload, so the gallery could never render the
// favorite/like limits or their counters (#1030).
// Get photo stats
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
+4 -2
View File
@@ -5,6 +5,7 @@ const secureImageService = require('../services/secureImageService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
const { parseBooleanInput } = require('../utils/parsers');
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver');
const { withLocalCopy } = require('../services/imageProcessor');
const { getStorage } = require('../services/storage');
@@ -323,8 +324,9 @@ router.get('/:slug/secure-download/:photoId/:token',
try {
const { photoId, token } = req.params;
// Check if downloads are allowed
if (req.event.allow_downloads === false) {
// Check if downloads are allowed. SQLite stores the flag as 0/1, so a
// strict `=== false` never fired there and the guard was inert (#1028).
if (!parseBooleanInput(req.event.allow_downloads, true)) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
@@ -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`);
+43 -9
View File
@@ -2,6 +2,38 @@ const { db, logActivity } = require('../database/db');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
// Every writable column on event_feedback_settings (#1030). The admin form
// posts its whole client-side state back, including UI-only keys that were
// never columns — `enable_rate_limiting`, `rate_limit_window_minutes`,
// `rate_limit_max_requests` — and spreading those into the UPDATE made knex
// throw, so the request 500'd and the "Enable feedback" toggle silently
// never persisted. Identity columns (id/event_id) and the timestamps stay
// server-managed. New columns MUST be added here.
const FEEDBACK_SETTINGS_COLUMNS = [
'feedback_enabled',
'allow_ratings',
'allow_likes',
'allow_comments',
'allow_favorites',
'require_name_email',
'moderate_comments',
'require_moderation',
'show_feedback_to_guests',
'identity_mode',
'max_favorites_per_guest',
'max_likes_per_guest'
];
function pickSettingsColumns(settings) {
const picked = {};
for (const column of FEEDBACK_SETTINGS_COLUMNS) {
if (Object.prototype.hasOwnProperty.call(settings || {}, column)) {
picked[column] = settings[column];
}
}
return picked;
}
class FeedbackService {
/**
* Get feedback settings for an event
@@ -53,25 +85,27 @@ class FeedbackService {
const existing = await db('event_feedback_settings')
.where('event_id', eventId)
.first();
const writable = pickSettingsColumns(settings);
if (existing) {
await db('event_feedback_settings')
.where('event_id', eventId)
.update({
...settings,
updated_at: new Date()
...writable,
updated_at: new Date().toISOString()
});
} else {
await db('event_feedback_settings').insert({
event_id: eventId,
...settings,
created_at: new Date(),
updated_at: new Date()
...writable,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
}
await logActivity('feedback_settings_updated', settings, eventId);
await logActivity('feedback_settings_updated', writable, eventId);
return this.getEventFeedbackSettings(eventId);
} catch (error) {
logger.error('Error updating feedback settings:', error);
+59 -9
View File
@@ -498,7 +498,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 +582,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 +612,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);
+7 -3
View File
@@ -135,7 +135,7 @@ async function collectFiles(includePhotos) {
* @param {string} [opts.outDir] where to write the file (defaults to a temp dir)
* @returns {Promise<{ filePath: string, manifest: object }>}
*/
async function createPicpeak({ includePhotos = false, outDir } = {}) {
async function createPicpeak({ includePhotos = false, includeFiles = true, outDir } = {}) {
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-export-'));
const dataDir = path.join(staging, 'data');
await fsp.mkdir(dataDir, { recursive: true });
@@ -150,7 +150,11 @@ async function createPicpeak({ includePhotos = false, outDir } = {}) {
// 2. Gather the non-recalculable blobs (PDFs, business-docs, uploads, and
// optionally original photos).
const files = await collectFiles(includePhotos);
// includeFiles:false is for the SQLite → Postgres migration (#1038): it moves
// rows between engines on the SAME install, so the storage volume is already
// correct. Copying every business doc through /tmp and back would only risk
// filling the temp disk.
const files = includeFiles ? await collectFiles(includePhotos) : [];
// 3. Manifest — everything the importer needs to validate + reconstruct.
const manifest = {
@@ -162,7 +166,7 @@ async function createPicpeak({ includePhotos = false, outDir } = {}) {
engine: isPostgres() ? 'pg' : 'sqlite',
latest_migration: await getLatestMigration(),
},
options: { includePhotos: !!includePhotos },
options: { includePhotos: !!includePhotos, includeFiles: !!includeFiles },
tables: tableMeta,
file_count: files.length,
// NOTE: contains secrets (SMTP password, admin hashes, API keys) in plain
+126 -11
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;
@@ -53,8 +54,16 @@ async function validateManifest(manifest) {
errors.push('This backup was created by a newer version of PicPeak. Update this instance first.');
}
const engine = isPostgres() ? 'pg' : 'sqlite';
if (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;
@@ -184,11 +193,82 @@ function serialiseJsonColumns(rows, jsonCols) {
});
}
// Cross-engine loads only (#1038): SQLite has no real date or boolean types, so
// its rows carry epoch numbers where Postgres wants a timestamp and 0/1 where
// Postgres wants a boolean. Both are rejected outright by pg
// ("date/time field value out of range: 1786548038763"). Coerce per column,
// driven by the TARGET schema so nothing is guessed from the value alone.
// Same-engine restores never call this and are byte-for-byte unchanged.
async function typedColumnsFor(trx, table) {
const info = await trx(table).columnInfo();
const timestamps = [];
const booleans = [];
for (const [name, meta] of Object.entries(info)) {
const type = String(meta.type || '').toLowerCase();
if (type.includes('timestamp') || type === 'date' || type === 'datetime') timestamps.push(name);
else if (type === 'boolean' || type === 'bool') booleans.push(name);
}
return { timestamps, booleans };
}
// SQLite writes Date objects as epoch MILLISECONDS in production, but some rows
// (and older installs) carry epoch seconds. 1e11 sits far past any plausible
// seconds value and far below any plausible ms value, so it separates them
// cleanly for every date this application will ever see.
function epochToIso(value) {
const n = Number(value);
if (!Number.isFinite(n)) return value;
const ms = Math.abs(n) < 1e11 ? n * 1000 : n;
const d = new Date(ms);
return Number.isNaN(d.getTime()) ? value : d.toISOString();
}
function coerceForTargetEngine(rows, { timestamps, booleans }) {
if (!timestamps.length && !booleans.length) return rows;
return rows.map((row) => {
const out = { ...row };
for (const col of timestamps) {
const v = out[col];
if (v === null || v === undefined || v === '') continue;
if (typeof v === 'number' || (typeof v === 'string' && /^-?\d+$/.test(v))) {
out[col] = epochToIso(v);
}
}
for (const col of booleans) {
const v = out[col];
if (v === null || v === undefined) continue;
if (typeof v === 'number') out[col] = v !== 0;
else if (typeof v === 'string') out[col] = !['0', 'false', ''].includes(v.toLowerCase());
}
return out;
});
}
// Whole-DB replace in one transaction with FK enforcement suspended (pg:
// session_replication_role=replica on the trx connection, reset before commit;
// sqlite: defer_foreign_keys so checks run at commit). knex_migrations is never
// in the data set, so the target's schema/migration state is left intact.
async function replaceAllTables(tables, dataDir, currentAdmin) {
// Advance Postgres identity sequences past the ids just inserted. Needed after
// any explicit-id load; here it backs the SQLite → Postgres migration (#1038).
async function resyncSequences(tables) {
if (!isPostgres()) return;
for (const table of tables) {
try {
if (!(await db.schema.hasColumn(table, 'id'))) continue;
const res = await db.raw('SELECT pg_get_serial_sequence(?, ?) AS seq', [table, 'id']);
const seq = res && res.rows && res.rows[0] && res.rows[0].seq;
if (!seq) continue; // `id` isn't a serial/identity column
await db.raw(
'SELECT setval(?, (SELECT COALESCE(MAX(id), 1) FROM ??), (SELECT MAX(id) IS NOT NULL FROM ??))',
[seq, table, table]
);
} catch (err) {
logger.warn(`[picpeak-import] could not resync sequence for ${table}: ${err.message}`);
}
}
}
async function replaceAllTables(tables, dataDir, currentAdmin, { crossEngine = false } = {}) {
await db.transaction(async (trx) => {
if (isPostgres()) {
try {
@@ -215,7 +295,18 @@ async function replaceAllTables(tables, dataDir, currentAdmin) {
const rows = parseNdjson(path.join(dataDir, `${table}.ndjson`));
if (!rows.length) continue;
const jsonCols = await jsonColumnsFor(trx, table);
await trx.batchInsert(table, serialiseJsonColumns(rows, jsonCols), 100);
let prepared = rows;
let toSerialise = jsonCols;
if (crossEngine) {
prepared = coerceForTargetEngine(prepared, await typedColumnsFor(trx, table));
// A sqlite-sourced archive already carries JSON columns as valid JSON
// TEXT, which is exactly what pg wants. Serialising again would store
// `{"a":1}` as the scalar string "{\"a\":1}" and would turn the JSON
// literal `null` into SQL NULL.
toSerialise = new Set();
}
prepared = serialiseJsonColumns(prepared, toSerialise);
await trx.batchInsert(table, prepared, 100);
}
await reinjectCurrentAdmin(trx, currentAdmin);
@@ -273,7 +364,7 @@ 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 }) {
const manifest = await readManifestFromZip(picpeakPath);
@@ -285,6 +376,16 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) {
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;
@@ -316,14 +417,22 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) {
logger.warn(`[picpeak-import] ignoring ${skipped.length} backup table(s) not present in this DB (or protected): ${skipped.join(', ')}`);
}
await replaceAllTables(tables, dataDir, currentAdmin);
await replaceAllTables(tables, dataDir, currentAdmin, { crossEngine });
// 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(() => {});
}
@@ -333,5 +442,11 @@ module.exports = {
importFromPicpeak,
readManifestFromZip,
validateManifest,
// exported for testing — the cross-engine coercion (#1038)
epochToIso,
coerceForTargetEngine,
typedColumnsFor,
reinjectCurrentAdmin,
// The cross-engine suite drives the post-restore sequence fixup directly.
resyncSequences,
};
+57 -4
View File
@@ -268,7 +268,61 @@ async function isSuperAdmin(actor, conn = db) {
* destination, while this function re-points the deal's events into it.
*/
async function linkDealToProject(dealUuid, projectId, conn = db, actor = null) {
if (!dealUuid || !projectId) return;
if (!projectId) return;
// Ownership of the DESTINATION. `attachDocumentToProject` reaches here behind
// requireProjectOwnership, but the quote/contract create+update paths do not:
// adminQuotes.js / adminContracts.js take `projectId` straight from the body
// behind `quotes.manage` / `contracts.manage`, which are permissions, not
// ownership. So the destination has to be vetted here, at the one choke point
// every caller shares, rather than relying on a route guard three of the four
// callers never had.
//
// Without it a scoped admin could point a new quote at a project they do not
// own: the lineage check below is skipped when the deal has produced no event
// yet (`eventIds.size` is 0), and an unassigned project ADOPTS the deal's
// customer instead of rejecting it. That writes their document into another
// admin's cockpit, and on an OWNERLESS project (created_by IS NULL — legacy
// rows migration 167's backfill could not attribute) it escalates: once the
// quote converts to an event, that event becomes the project's only linked
// event, which is exactly the condition ownedProjectsSubquery's second branch
// grants ownership on — handing the caller read access to whatever documents
// were already attached there.
//
// Mirrors ownedProjectsSubquery (middleware/ownership.js) rather than calling
// it, because that helper binds the module-level `db` and this runs inside the
// caller's transaction.
if (actor?.id && !(await isSuperAdmin(actor, conn))) {
const owned = await conn('projects')
.where({ id: projectId })
.where((w) => {
w.where('created_by', actor.id)
.orWhere((noOwner) => {
noOwner
.where((c) => c
.whereNull('created_by')
.orWhereNotIn('created_by', conn('admin_users').select('id')))
.whereExists(
conn('events').select(conn.raw('1')).whereRaw('events.project_id = projects.id'),
)
.whereNotExists(
conn('events').select(conn.raw('1')).whereRaw('events.project_id = projects.id')
.whereNotNull('events.created_by').whereNot('events.created_by', actor.id),
);
});
})
.first('id');
if (!owned) {
throw new AppError('Project not found', 404, 'PROJECT_NOT_FOUND');
}
}
// Nothing to cascade without a deal, but the destination above still had
// to be vetted: every caller writes `project_id` onto its own row BEFORE
// calling us, and `deal_uuid` is nullable (migration 107). A legacy quote
// with no deal would otherwise return here having bypassed the check while
// its foreign project link stood.
if (!dealUuid) return;
// Collect ALL the deal's customers across its quote/contract/invoice lineage
// AND every event it converted into — BEFORE mutating anything, so a link
@@ -310,9 +364,8 @@ async function linkDealToProject(dealUuid, projectId, conn = db, actor = null) {
throw new AppError('That belongs to a different customer than this project', 422, 'PROJECT_CUSTOMER_MISMATCH');
}
// Ownership of the LINEAGE, not just the destination (GHSA-wrg5). The route
// guard (requireProjectOwnership) only vets `projectId`; the writes below
// re-point every event this deal produced into it. Without this check an
// Ownership of the LINEAGE, not just the destination (GHSA-wrg5). The writes
// below re-point every event this deal produced into it. Without this check an
// editor could create an empty project, attach another admin's quote, and
// pull that admin's events — plus the invoices, emails and gallery that roll
// up with them — into a project they own and can read via /:id/overview.
+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 ||
+449
View File
@@ -0,0 +1,449 @@
'use strict';
/**
* Which database engine is this process actually using, and is that what the
* operator intended? (#1038)
*
* knexfile.js selects its config block by NODE_ENV, and the `development`
* block defaults to sqlite3. The Docker image never set NODE_ENV, so every
* deployment that doesn't go through our compose files — Kubernetes, Helm,
* plain `docker run` — silently landed on SQLite and ignored DB_HOST /
* DB_USER / DB_PASSWORD entirely. wait-for-db.sh is shell and reads DB_HOST
* directly, so the same container happily reported "PostgreSQL is up" while
* the app wrote to a SQLite file.
*
* Now that the image pins NODE_ENV=production, those installs would resolve to
* Postgres on their next pull — and come up against an EMPTY database, which
* reads as total data loss. Blocking the boot would protect the data but take
* the galleries offline for an operator who did nothing wrong, so instead we
* STAY on SQLite (the engine that holds their data), say so loudly, and point
* at the migration script. Nothing moves until the operator decides.
*
* decideBootEngine() is pure so the matrix is testable; the probes around it
* are deliberately thin.
*/
const fs = require('fs');
const { resolveSqliteFilename } = require('./sqlitePath');
// Shared with knexfile so the engine guard can never probe a different target
// than the application opens (#1038).
const { pgConnectionFromEnv } = require('./pgConnection');
// Diagnostics go through an injected sink, never a module-level logger: the
// resolver's STDOUT is a protocol channel (wait-for-db.sh captures it), and the
// app logger writes there whenever LOG_TO_CONSOLE=true.
const warnToStderr = (msg) => process.stderr.write(`${msg}\n`);
/** Absolute path of the SQLite file this install would use — the SAME
* resolution knexfile performs, so the guard can never probe a different file
* than the one knex opens. */
function resolveSqlitePath() {
return resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db');
}
/** Human-readable "engine + target", safe to log — never includes credentials. */
function describeEngine(knexConfig) {
const client = knexConfig?.client || 'unknown';
if (client === 'pg') {
const c = knexConfig.connection || {};
return `postgres (${c.host || 'unknown-host'}:${c.port || 5432}/${c.database || 'unknown-db'})`;
}
const filename = knexConfig?.connection?.filename || resolveSqlitePath();
return `sqlite (${filename})`;
}
/**
* Which engine should this boot actually use?
*
* @param {object} state
* @param {string} state.configuredClient what knexfile resolved to
* @param {string=} state.explicitClient DATABASE_CLIENT, if the operator set it
* @param {boolean} state.pgHasData the Postgres target already holds galleries
* @param {boolean} state.sqliteHasData a SQLite file exists AND holds events
* @returns {{ client: string, overridden: boolean, reason: string|null }}
*/
function decideBootEngine({
configuredClient, explicitClient, pgHasData, sqliteHasData,
migrationInProgress = false, migrationCompleted = false, pgConfigured = false,
}) {
// A migration that never finished outranks everything, including an explicit
// DATABASE_CLIENT=pg: Postgres may hold a half-written copy while SQLite is
// still the database of record. Deleting the marker is the documented
// override. (Explicit sqlite3 already points at the data, so leave it alone.)
if (migrationInProgress && sqliteHasData && explicitClient !== 'sqlite3') {
return { client: 'sqlite3', overridden: true, reason: 'migration-incomplete' };
}
// The data was migrated to Postgres, but nothing in the environment says so:
// DATABASE_CLIENT is unset and NODE_ENV still resolves to the development
// block, i.e. sqlite3. That is the state the affected installs are IN — it is
// why they ended up on SQLite in the first place — so an operator can easily
// migrate before fixing it. The source file has been renamed away by then, so
// honouring the implicit sqlite3 would create a NEW, empty database and serve
// it. The marker is durable proof of where the data actually is.
if (!explicitClient && configuredClient !== 'pg' && migrationCompleted && pgConfigured) {
return { client: 'pg', overridden: true, reason: 'migrated-to-postgres' };
}
// An explicit DATABASE_CLIENT is an instruction, not a guess. Never override
// it — this is also the documented way to force Postgres and start fresh.
if (explicitClient) {
return {
client: explicitClient,
overridden: false,
reason: explicitClient === 'pg' && sqliteHasData && !pgHasData
? 'explicit-pg-leaves-sqlite-behind'
: null,
};
}
// A migration started and never finished. Postgres may hold a partial copy,
// which would otherwise read as "occupied" and win — while SQLite is still
// the database of record.
if (configuredClient === 'pg' && migrationInProgress && sqliteHasData) {
return { client: 'sqlite3', overridden: true, reason: 'migration-incomplete' };
}
// Both sides hold data and nothing records which is authoritative. This is
// the shape of an install that ran on Postgres, silently fell to SQLite when
// NODE_ENV was lost, and kept working there: the Postgres rows are real but
// stale, and the SQLite rows are real and newer. A completed migration would
// have left a marker; without one, guessing either way hides data and splits
// subsequent writes across two databases. Stop and let a human decide.
if (configuredClient === 'pg' && !migrationCompleted && pgHasData && sqliteHasData) {
return { client: null, overridden: false, reason: 'ambiguous-both-populated' };
}
// Configured for Postgres, Postgres holds no galleries, and real data sits in
// a SQLite file: this install has been unknowingly running on SQLite. Keep
// serving from where the data actually is. Deliberately keyed on DATA, not on
// "has tables" — a stray migration run against the empty Postgres would
// otherwise blind this check and strand the operator on an empty database.
if (configuredClient === 'pg' && !pgHasData && sqliteHasData) {
return { client: 'sqlite3', overridden: true, reason: 'stranded-sqlite-data' };
}
return { client: configuredClient, overridden: false, reason: null };
}
/** Marker written by scripts/migrate-sqlite-to-postgres.js once the data is in
* Postgres. Its presence pins the install to Postgres for good: without it, a
* Postgres that is merely EMPTY (every gallery deleted, say) would look
* identical to one that was never migrated, and the boot would fall back to a
* stale SQLite file that has been out of date since the migration. */
function migrationMarkerPath(sqlitePath = resolveSqlitePath()) {
return `${sqlitePath}.migrated-to-postgres`;
}
function hasMigrationMarker(sqlitePath = resolveSqlitePath()) {
return fs.existsSync(migrationMarkerPath(sqlitePath));
}
/** The marker's contents, or null when absent/unreadable. */
function readMigrationMarker(sqlitePath = resolveSqlitePath()) {
try {
return JSON.parse(fs.readFileSync(migrationMarkerPath(sqlitePath), 'utf8'));
} catch (_) {
return null;
}
}
/** `host:port/database`, the identity the migration records and compares. */
function currentPgTargetId() {
const c = pgConnectionFromEnv();
return `${c.host}:${c.port}/${c.database}`;
}
/** Written before the migration touches Postgres, cleared only on success.
* While it exists, Postgres may hold a PARTIAL copy — or just the bootstrap
* admin that schema creation seeds — and SQLite is still the authoritative
* database. Without this pin, a migration that failed after writing anything
* to Postgres would make the next boot switch engines and hide the real data. */
function migrationInProgressPath(sqlitePath = resolveSqlitePath()) {
return `${sqlitePath}.migration-in-progress`;
}
function hasMigrationInProgress(sqlitePath = resolveSqlitePath()) {
return fs.existsSync(migrationInProgressPath(sqlitePath));
}
// Tables that are EMPTY on a freshly migrated schema, so a row in any of them
// means a human has used this install. Deliberately wider than `events`:
// judging occupancy by galleries alone would abandon an install whose galleries
// were all deleted but whose admins, customers and accounting records remain.
// Mirrors USER_DATA_TABLES in scripts/migrate-sqlite-to-postgres.js.
const USER_DATA_TABLES = [
'events', 'photos', 'photo_feedback', 'admin_users', 'customer_accounts',
'quotes', 'invoices', 'projects', 'expenses', 'inbound_documents',
];
// core/001_init.js seeds an admin with must_change_password = true when
// ADMIN_PASSWORD is set; setupService writes false once a human completes
// first-run setup. So the FLAG, not the table, is what distinguishes an
// untouched bootstrap row from a real account. Dropping the whole table (as an
// earlier revision did) made a legitimately set-up Postgres look empty, which
// would hand the install to a stale SQLite file and lose the admin's
// credentials and configuration.
const isUntouchedBootstrapRow = (v) => v === true || v === 1 || v === '1';
// Has anyone actually USED this install's admin accounts? Layered, because no
// single column survives every path:
// - more than one admin → somebody created accounts
// - any admin has logged in → real use, even if the password was later reset
// - must_change_password false → first-run setup was completed
// Only the exact shape core/001_init.js leaves behind — one admin, never logged
// in, still flagged — reads as an untouched bootstrap seed.
function adminsIndicateUse(rows) {
if (rows.length > 1) return true;
return rows.some((r) => r.last_login || !isUntouchedBootstrapRow(r.must_change_password));
}
async function countsAsUse(conn, table, { ignoreBootstrapAdmins }) {
if (table === 'admin_users' && ignoreBootstrapAdmins) {
const cols = ['must_change_password'];
if (await conn.schema.hasColumn('admin_users', 'last_login')) cols.push('last_login');
return adminsIndicateUse(await conn('admin_users').select(cols));
}
const row = await conn(table).count('* as count').first();
return Number(row?.count || 0) > 0;
}
async function anyUserData(conn, { ignoreBootstrapAdmins = false } = {}) {
for (const table of USER_DATA_TABLES) {
if (!(await conn.schema.hasTable(table))) continue;
if (await countsAsUse(conn, table, { ignoreBootstrapAdmins })) return true;
}
return false;
}
/** True when a SQLite file exists and carries user data. */
async function probeSqliteData(sqlitePath = resolveSqlitePath(), onWarn = warnToStderr) {
if (hasMigrationMarker(sqlitePath)) return false;
if (!fs.existsSync(sqlitePath)) return false;
const knex = require('knex');
const probe = knex({
client: 'sqlite3',
connection: { filename: sqlitePath },
useNullAsDefault: true,
});
try {
// Same discrimination as the Postgres side. An accidental SQLite database
// gets a seeded admin from core/001_init.js when ADMIN_PASSWORD is set, and
// counting that as use would make a healthy Postgres install look like a
// both-populated conflict and refuse to boot. A setup-completed or
// logged-in admin still counts.
return await anyUserData(probe, { ignoreBootstrapAdmins: true });
} catch (err) {
// Unreadable or corrupt: fail CLOSED. Reporting "no data" here would switch
// the install to an empty Postgres — the precise failure this module exists
// to prevent. Staying on SQLite surfaces the real error instead.
onWarn(
`[database-engine] SQLite at ${sqlitePath} exists but could not be probed (${err.message}); `
+ 'assuming it holds data and staying on it.'
);
return true;
} finally {
await probe.destroy();
}
}
/** True when the configured Postgres target already holds user data. */
async function probePgData(pgConnection, onWarn = warnToStderr) {
const knex = require('knex');
const probe = knex({ client: 'pg', connection: pgConnection, pool: { min: 0, max: 1 } });
try {
// Two very different failures hide behind one catch, and they need opposite
// answers, so establish reachability first — this branch returns, so
// everything below it is reachable-by-construction.
try {
await probe.raw('SELECT 1');
} catch (err) {
// Cannot reach Postgres at all. The app could not run on it either way,
// so report "occupied" to avoid diverting a healthy pg install to a stale
// SQLite file over a transient network blip — startup then fails with the
// real connection error, exactly as it always has.
onWarn(`[database-engine] Postgres unreachable while probing (${err.message}); leaving the configured engine alone.`);
return true;
}
try {
// Substantive use only: an untouched bootstrap admin does not make a
// Postgres target worth switching to, but a completed setup does.
return await anyUserData(probe, { ignoreBootstrapAdmins: true });
} catch (err) {
// Connected, but the query failed — a half-built or damaged schema. That
// is NOT evidence of data: reporting "occupied" here would boot the empty
// Postgres and hide a populated SQLite file, the exact failure this guard
// exists to prevent. Say "not proven occupied" and let the SQLite side win
// if it actually holds data.
onWarn(`[database-engine] Postgres reachable but could not be inspected (${err.message}); treating it as unproven rather than occupied.`);
return false;
}
} finally {
await probe.destroy();
}
}
const CONFLICT_MESSAGE = (sqlitePath, pgTarget) => `
${'='.repeat(78)}
REFUSING TO START — two databases, both with data, and no record of which is current.
sqlite : ${sqlitePath}
postgres : ${pgTarget}
This is what an install looks like after it ran on PostgreSQL, lost NODE_ENV or
DATABASE_CLIENT, and kept working on SQLite without anyone noticing (see
https://github.com/PicPeak/picpeak/issues/1038). The PostgreSQL rows are real
but probably old; the SQLite rows are real and probably newer.
Starting either one would hide the other's galleries and split every new upload
across two databases, so PicPeak will not choose for you. Compare them, then say
which is authoritative:
DATABASE_CLIENT=sqlite3 keep serving the SQLite file (its data is newer)
DATABASE_CLIENT=pg keep serving PostgreSQL
To combine them, start on SQLite and run: node scripts/migrate-sqlite-to-postgres.js
(it replaces the PostgreSQL contents with the SQLite data and records the switch).
${'='.repeat(78)}
`.trim();
const STRANDED_WARNING = (sqlitePath, pgTarget) => `
${'='.repeat(78)}
STILL RUNNING ON SQLITE — Postgres is configured but empty.
data in use : ${sqlitePath}
configured : ${pgTarget} (no galleries in it)
This install has been running on SQLite. Until now the image left NODE_ENV
unset, so knexfile.js fell back to its development block and ignored DB_HOST /
DB_USER / DB_PASSWORD — see https://github.com/PicPeak/picpeak/issues/1038.
Nothing has changed for you: your galleries are served from the SQLite file
above, exactly as before. Switching engines now would start from an empty
database, so PicPeak will not do that on its own.
To move your data to Postgres when you are ready:
node scripts/migrate-sqlite-to-postgres.js
It copies every row into Postgres and leaves the SQLite file untouched as a
fallback. To go to Postgres WITHOUT the data, set DATABASE_CLIENT=pg.
${'='.repeat(78)}
`.trim();
/**
* Resolve the engine for this boot, log what happened, and return the client
* the process should use. Called before migrations touch anything.
*/
async function resolveBootEngine({ knexConfig, logger }) {
const explicitClient = process.env.DATABASE_CLIENT || null;
const configuredClient = knexConfig?.client;
const sqlitePath = resolveSqlitePath();
// Probe whenever Postgres is the engine in play — including when it was named
// explicitly, otherwise the "leaving SQLite behind" warning is unreachable.
const effectiveClient = explicitClient || configuredClient;
const migrationInProgress = hasMigrationInProgress(sqlitePath);
const marker = readMigrationMarker(sqlitePath);
const migrationCompleted = hasMigrationMarker(sqlitePath);
// The marker vouches for ONE Postgres. If the configuration now points at a
// different one, it says nothing about that target — and trusting it would
// boot an unrelated empty database while the real data sits in the recorded
// one and in the renamed rollback copy.
const markerTargetMismatch = Boolean(
migrationCompleted && marker && marker.target && marker.target !== currentPgTargetId(),
);
const pgConfigured = Boolean(process.env.DB_HOST || process.env.DB_PASSWORD);
// Probe when Postgres is in play, and also whenever a migration is pinned or
// finished — those decisions need to know what each side holds.
const probing = effectiveClient === 'pg' || migrationInProgress || migrationCompleted;
const decision = decideBootEngine({
configuredClient,
explicitClient,
pgHasData: probing
? await probePgData(
knexConfig.client === 'pg' ? knexConfig.connection : pgConnectionFromEnv(),
(m) => logger.warn(m),
)
: true,
sqliteHasData: probing ? await probeSqliteData(sqlitePath, (m) => logger.warn(m)) : false,
migrationInProgress,
migrationCompleted,
pgConfigured,
});
if (markerTargetMismatch) {
logger.error(`
${'='.repeat(78)}
REFUSING TO START — this install was migrated to a different PostgreSQL.
migrated to : ${marker.target}
configured : ${currentPgTargetId()}
${migrationMarkerPath(sqlitePath)} records where the data was moved. The current
settings point somewhere else, so starting would open an unrelated database and
present an empty installation while your galleries stay in the one above.
Either restore the original connection settings, or — if this move is deliberate
and the data is already in the new target — update the "target" field in that
marker file to match.
${'='.repeat(78)}
`.trim());
return { client: null, overridden: false, reason: 'marker-target-mismatch' };
}
if (decision.reason === 'ambiguous-both-populated') {
logger.error(CONFLICT_MESSAGE(sqlitePath, describeEngine({
client: 'pg', connection: pgConnectionFromEnv(),
})));
return decision;
}
if (decision.reason === 'migrated-to-postgres') {
logger.warn(
`This install's data was migrated to PostgreSQL (${migrationMarkerPath(sqlitePath)}), but the `
+ 'environment still resolves to SQLite. Using PostgreSQL — set NODE_ENV=production (or '
+ 'DATABASE_CLIENT=pg) to make that explicit.'
);
} else if (decision.reason === 'migration-incomplete') {
logger.warn(
`A SQLite → PostgreSQL migration did not finish (${migrationInProgressPath(sqlitePath)} is still `
+ 'present), so PostgreSQL may hold a partial copy. Staying on SQLite, which is still the '
+ 'database of record. Re-run scripts/migrate-sqlite-to-postgres.js with the backend stopped; '
+ 'delete that file only if you have decided to abandon the migration.'
);
} else if (decision.overridden && decision.reason === 'stranded-sqlite-data') {
logger.warn(STRANDED_WARNING(sqlitePath, describeEngine(knexConfig)));
} else if (decision.reason === 'explicit-pg-leaves-sqlite-behind') {
logger.warn(
'DATABASE_CLIENT=pg is set explicitly, so PicPeak is starting on an empty Postgres while '
+ `gallery data exists at ${sqlitePath}. Run scripts/migrate-sqlite-to-postgres.js to bring it across.`
);
}
// Describe what was DECIDED, not what knexfile said: after a marker override
// knexConfig still describes SQLite while the process goes to Postgres.
logger.info(`Database engine: ${decision.client === 'pg'
? describeEngine(knexConfig.client === 'pg' ? knexConfig : { client: 'pg', connection: pgConnectionFromEnv() })
: `sqlite (${sqlitePath})`}`);
return decision;
}
module.exports = {
resolveSqlitePath,
pgConnectionFromEnv,
isUntouchedBootstrapRow,
adminsIndicateUse,
migrationMarkerPath,
hasMigrationMarker,
readMigrationMarker,
currentPgTargetId,
migrationInProgressPath,
hasMigrationInProgress,
describeEngine,
decideBootEngine,
probeSqliteData,
probePgData,
resolveBootEngine,
};
+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;
}
+38
View File
@@ -0,0 +1,38 @@
'use strict';
/**
* The PostgreSQL target, resolved in exactly one place (#1038).
*
* Three different defaults for the same connection used to coexist:
*
* knexfile development : localhost / postgres / photo_sharing
* knexfile production : db / picpeak / picpeak
* wait-for-db.sh : postgres / picpeak / picpeak (and it EXPORTS them)
*
* so a process that probed or migrated against one could hand over to a process
* that opened another. Two review rounds in a row traced back to that, each
* time through a caller the previous fix had not covered — the engine guard,
* the migration CLI's child phases, then server.js.
*
* The host and user defaults are the ones a running container actually uses,
* because wait-for-db.sh resolves and exports them before anything starts.
* The database name matters most: a wrong host or user fails loudly at connect
* time, while a wrong database name connects fine and presents an empty
* installation.
*
* Reading process.env on every call is deliberate — the entrypoint and
* server.js both normalise these variables before the app opens a pool.
*/
function pgConnectionFromEnv() {
return {
host: process.env.DB_HOST || 'postgres',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'picpeak',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'picpeak',
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
};
}
module.exports = { pgConnectionFromEnv };
+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'),
]);
+52
View File
@@ -0,0 +1,52 @@
'use strict';
/**
* Where this install's SQLite database lives.
*
* Extracted from knexfile.js so the engine guard (#1038) resolves EXACTLY the
* same path knex opens. When the two disagree — a DATABASE_PATH with stray
* whitespace, or the legacy duplicated-backend form this collapses — the guard
* probes a file nobody uses, concludes there is no SQLite data, and lets the
* boot switch to an empty Postgres while the real galleries sit in the file it
* failed to look at.
*
* Behaviour is unchanged from the original; only its home moved.
*/
const path = require('path');
const BACKEND_ROOT = path.resolve(__dirname, '..', '..');
function resolveSqliteFilename(filenameEnv, baseDir = BACKEND_ROOT) {
const fallback = path.join(baseDir, './data/photo_sharing.db');
if (!filenameEnv) {
return fallback;
}
const trimmed = String(filenameEnv).trim();
if (!trimmed) {
return fallback;
}
let resolved;
if (path.isAbsolute(trimmed)) {
resolved = trimmed;
} else if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
resolved = path.resolve(baseDir, trimmed);
} else {
resolved = path.join(baseDir, trimmed);
}
const normalized = path.normalize(resolved);
const baseSuffix = path.relative(path.parse(baseDir).root, path.normalize(baseDir));
const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`;
if (normalized.includes(duplicatePattern)) {
return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`);
}
return normalized;
}
module.exports = { resolveSqliteFilename };
+40
View File
@@ -59,6 +59,16 @@ host="${DB_HOST:-postgres}"
port="${DB_PORT:-5432}"
user="${DB_USER:-picpeak}"
target_db="${DB_NAME:-picpeak}"
# Hand the app EXACTLY the connection this script verified. knexfile's
# production block defaults DB_HOST to `db` while this script defaults to
# `postgres`, so a bare `docker run` with no DB_HOST would have had the
# readiness check pass against one host and the app then dial another (#1038
# review). Compose sets DB_HOST explicitly and is unaffected.
export DB_HOST="$host"
export DB_PORT="$port"
export DB_USER="$user"
export DB_NAME="$target_db"
# Use target database for checks - the picpeak user may not have access to 'postgres' database
default_db="${DB_CHECK_DB:-$target_db}"
@@ -120,6 +130,36 @@ echo "Ensuring storage directories exist..."
STORAGE_BASE="${STORAGE_PATH:-/app/storage}"
mkdir -p "$STORAGE_BASE/events/active" "$STORAGE_BASE/events/archived" "$STORAGE_BASE/thumbnails" 2>/dev/null || true
# Resolve which database engine this boot should use (#1038) BEFORE migrations
# run, while the Postgres target is still untouched. An install that has been
# unknowingly running on SQLite (the image used to leave NODE_ENV unset, so
# knexfile.js fell back to sqlite3 and ignored DB_HOST/DB_USER/DB_PASSWORD)
# keeps serving from its SQLite file instead of coming up against an empty
# Postgres. The exported value survives the `exec` below, so the migration
# runner and the server agree on the engine.
RESOLVED_DB_CLIENT="$(node scripts/resolve-db-engine.js)"
RESOLVER_STATUS=$?
# Exit 3 means two populated databases with no record of which is current
# (#1038). Starting either would hide the other's data, so stop here — the
# resolver has already printed what to do.
if [ "$RESOLVER_STATUS" = "3" ]; then
exit 1
fi
# Validate rather than trust: anything unexpected on stdout (a stray log line
# from a library that writes to the console) must not become DATABASE_CLIENT,
# which would break knexfile for every process that follows.
case "$RESOLVED_DB_CLIENT" in
pg|sqlite3)
export DATABASE_CLIENT="$RESOLVED_DB_CLIENT"
;;
"")
>&2 echo "Database engine resolver returned nothing; falling back to the configured client."
;;
*)
>&2 echo "Database engine resolver returned an unexpected value; ignoring it and falling back to the configured client."
;;
esac
# Run migrations (use safe runner in production). Invoked via node directly —
# the runtime image no longer ships npm (see Dockerfile: its bundled deps kept
# tripping CVE scanners while npm itself never runs in production).
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.45.13",
"version": "3.46.2",
"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" />
+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);
}
}
+10 -3
View File
@@ -524,11 +524,18 @@ export const EventDetailsPage: React.FC = () => {
// Update event details
updateMutation.mutate(updateData);
// Update feedback settings separately
// Update feedback settings separately. This is its own request, so a
// failure here is NOT covered by updateMutation's onError (#1030) — the
// old bare catch left the admin looking at "Event updated successfully"
// while the Guest Feedback toggle silently never persisted.
try {
await feedbackService.updateEventFeedbackSettings(id!, feedbackSettings);
} catch {
// Error already handled by mutation
queryClient.invalidateQueries({ queryKey: ['admin-event-feedback-settings', id] });
} catch (error: any) {
toast.error(
error?.response?.data?.error
|| t('feedback.settingsUpdateError', 'Failed to update settings')
);
}
};
+8 -1
View File
@@ -23,8 +23,15 @@ const STATE_POLL_MS = 3000;
// Prefer the aspect-preserved preview (≤1920px) over the full original; fall
// back to the standard url. Always absolutised so it works whether the API is
// same-origin or an explicit absolute base.
//
// Deliberately never `hero_url` (#1015): that tier is cover-cropped to 16:9
// for gallery header banners, so with fit='contain' the show letterboxed an
// already-cropped frame — portrait photos lost their top and bottom and the
// "Black Bars (No crop)" setting looked broken. `slideshow_url` is the same
// aspect-preserved preview as `preview_url` but is always emitted, so the
// crop can't come back when lightbox previews are off (the default).
function photoSrc(photo: Photo): string {
return buildResourceUrl(photo.preview_url || photo.hero_url || photo.url);
return buildResourceUrl(photo.slideshow_url || photo.preview_url || photo.url);
}
// CSS `filter` applied directly to the image for filters that are pure tone
+5
View File
@@ -116,6 +116,11 @@ export interface Photo {
// ≤1920px JPEG; the lightbox prefers it over `url` for image photos
// and falls back to `url` when null (off, video, or not yet generated).
preview_url?: string | null;
// Aspect-preserved ≤1920px source for the fullscreen slideshow (#1015).
// Always set for image photos, unlike `preview_url` — the slideshow must
// never fall back to `hero_url`, which is a 16:9 centre crop and makes
// the "Black Bars (No crop)" fit letterbox an already-cropped frame.
slideshow_url?: string | null;
secure_url_template?: string;
download_url_template?: string;
requires_token?: boolean;
+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(