Compare commits

...

45 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Review round 12.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Review round 15.

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

    SQLITE_CONSTRAINT: NOT NULL constraint failed: events.expires_at

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Lockfile holds exactly one entry per package, all at or above the fixed
version; the image installs via npm ci --omit=dev.
2026-08-04 14:36:10 +02:00
Paul Nothaft 3f7364be8e chore(stable): release 3.45.13 (#972)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-03 21:26:03 +02:00
Paul Nothaft 2d0e6ab2dc fix(projects): stop the cockpit offering email controls the API rejects (stable) (#977)
Closes #969 on stable. Backport of #976.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 21:18:43 +02:00
Paul Nothaft 0c73bf2cdc chore(stable): release 3.45.12 (#955)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-02 20:25:40 +02:00
Paul Nothaft 2c7b5dfd02 fix(security): redact gallery share tokens from analytics tracking (GHSA-7m6c) (stable) (#953)
* fix(security): redact gallery share tokens from analytics page-view tracking (GHSA-7m6c)

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

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

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

---------

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

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

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

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

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

---------

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

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-08-02 08:38:47 +02:00
Paul Nothaft 2c5a094c5c chore(stable): release 3.45.11 (#936)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-08-01 17:38:06 +02:00
Paul Nothaft 2462ba6897 fix(security): close authorization/ownership gaps (token scope, mass-assignment, category hero, project docs) (stable) (#944)
* fix(security): close authorization/ownership gaps (token scope, mass-assignment, category hero, project docs)

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

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

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

---------

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

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

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

---------

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

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

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

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

---------

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

---------

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

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

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

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

New adminPhotoContentType suite pins all four MIME cases.

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

External review findings, all verified:

- The header is now ALWAYS image/* or video/*. photos.mime_type is
  never echoed verbatim unless it is a video/ type — the chunked-upload
  path stores the client-sent MIME unvalidated, so a stored text/html
  served inline under the app origin was a same-origin XSS hazard.
- MIME-less videos map from the extension via the shared
  EXTENSION_TO_MIME (.mov → video/quicktime, .webm → video/webm)
  instead of a blanket video/mp4 that would mislabel them.
- Images ignore the stored MIME entirely: migration 039 backfilled
  image/jpeg onto every legacy row (PNGs included), so trusting it
  would regress previously-correct extension-derived types. Extension
  wins, normalized (jpg → image/jpeg).

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

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

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

Two review findings on the admin expiry surfaces:

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

---------

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-07-29 17:59:06 +02:00
153 changed files with 10530 additions and 615 deletions
+3 -2
View File
@@ -56,8 +56,9 @@ DB_NAME=picpeak_prod
# Admin Account (initial setup) — OPTIONAL
# Leave these unset (default) to create your admin IN THE BROWSER on first run:
# open /admin and PicPeak shows a setup screen. The one-time setup token is
# printed to the backend logs (`docker compose logs backend | grep -i "setup token"`)
# and saved to data/SETUP_TOKEN.
# written to data/SETUP_TOKEN with mode 0600 — read it with
# `docker compose exec backend cat /app/data/SETUP_TOKEN`. It is NOT logged
# unless that write fails, so it never sits in `docker logs`.
# Set ADMIN_PASSWORD to auto-create the admin on first boot instead (legacy;
# credentials written to data/ADMIN_CREDENTIALS.txt).
#ADMIN_USERNAME=admin
+6 -26
View File
@@ -27,32 +27,12 @@ jobs:
manifest-file: .release-please-manifest.json
target-branch: stable
# Auto-approve + auto-merge the open stable release PR. See the beta
# workflow for the full rationale. Skipped on the release-cutting run and
# whenever no PAT is configured.
- name: Auto-approve and enable auto-merge on the release PR
if: ${{ steps.release.outputs.release_created != 'true' }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout in this job — set the repo explicitly so gh works
# without a git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping auto-merge (manual review still required)."
exit 0
fi
pr=$(gh pr list --head release-please--branches--stable --state open --json number --jq '.[0].number // empty')
if [ -n "$pr" ]; then
# Approve as github-actions[bot] (GITHUB_TOKEN, ≠ the PAT author) so it
# is a valid review; enable auto-merge as the PAT so the merge commit is
# attributed to a real identity and triggers the tag-cutting run (#719).
gh pr review "$pr" --approve --body "Automated approval — release-please version bump + changelog (#719)." || true
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto || true
else
echo "No open release PR to auto-merge."
fi
# NOTE: stable release PRs are intentionally NOT auto-merged here
# anymore. Fixes accumulate in the rolling release PR and are cut as
# ONE patch version per day by release-stable-daily.yml (18:00 UTC,
# or on demand via workflow_dispatch / a manual merge of the release
# PR). Beta keeps instant releases — see release-please-beta.yml —
# because same-day reporter verification depends on it.
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
@@ -0,0 +1,86 @@
name: Cut Stable Release (daily batch)
# Stable fixes accumulate in release-please's rolling release PR instead of
# each cutting its own patch version (the old per-merge auto-merge produced
# e.g. 3.45.8 AND 3.45.9 on the same day). This workflow merges the open
# stable release PR once a day, so a day of N bugfixes ships as ONE version
# with all N changelog entries — and one Docker build instead of N.
#
# - schedule only fires from the default branch (main); the stable copy of
# this file is inert and exists to keep the branches in sync.
# - Need a release NOW? Run this via workflow_dispatch, or merge the
# release PR by hand — the schedule is a default, not a gate.
# - Approval/merge mechanics mirror the old inline step (#719): approve as
# github-actions[bot] (GITHUB_TOKEN, a valid distinct reviewer), enable
# auto-merge as the PAT so the merge attributes to a real identity and
# triggers the tag-cutting run. --auto waits for green checks.
on:
schedule:
- cron: '0 18 * * *'
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
merge-stable-release-pr:
runs-on: ubuntu-latest
steps:
- name: Approve and enable auto-merge on the open stable release PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_PAT: ${{ secrets.RELEASE_PLEASE_TOKEN }}
# No checkout — set the repo explicitly so gh works without a
# git remote (same pattern as whatsnew, 2a5f0a8).
GH_REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_PAT" ]; then
echo "RELEASE_PLEASE_TOKEN not set — skipping (manual review required)."
exit 0
fi
# Strict selection (review P1): this job runs daily even without a
# stable push, and `gh pr list --head` matches the branch NAME only
# — a fork PR can spoof `release-please--branches--stable`. Pin the
# base to stable AND require a same-repo head (isCrossRepository
# == false); a fork PR is cross-repository, so it can never be
# picked and auto-merged with the privileged PAT.
pr=$(gh pr list \
--base stable \
--head release-please--branches--stable \
--state open \
--json number,isCrossRepository \
--jq '[.[] | select(.isCrossRepository == false)] | .[0].number // empty')
if [ -z "$pr" ]; then
echo "No open same-repo stable release PR — nothing to cut today."
exit 0
fi
# Approve is tolerant — a pre-existing approval already satisfies
# branch protection and re-approving can return non-zero.
gh pr review "$pr" --approve --body "Automated approval — daily stable release batch (release-please version bump + changelog)." || echo "::warning::approve returned non-zero (PR may already be approved)"
# But the auto-merge enable is the load-bearing step: this scheduled
# job is the ONLY automatic stable cut, so DON'T swallow its failure
# (review P2) — an expired/under-scoped PAT would otherwise stop
# releases while the workflow stays green.
GH_TOKEN="$RELEASE_PAT" gh pr merge "$pr" --squash --auto
# `gh pr merge --auto` merges IMMEDIATELY when the required checks
# are already green — the normal case at 18:00, since the fixes
# merged hours earlier and CI passed. So success is EITHER the PR is
# already merged OR an auto-merge request is now pending; only a PR
# that is still open with no auto-merge request is a real failure
# (expired/under-scoped PAT) worth failing the job on (review round 2).
# One snapshot of both fields (review round 3): querying state and
# autoMergeRequest separately races — auto-merge can complete
# between the two calls, so the first sees OPEN and the second sees
# the request already cleared on the now-merged PR → false failure.
read -r state automerge < <(gh pr view "$pr" --json state,autoMergeRequest \
--jq '[.state, (.autoMergeRequest != null)] | @tsv')
if [ "$state" = "MERGED" ]; then
echo "Stable release PR #$pr merged immediately (checks were already green)."
elif [ "$automerge" = "true" ]; then
echo "Auto-merge enabled on stable release PR #$pr — merges when checks are green."
else
echo "::error::stable release PR #$pr is still open with no auto-merge — check RELEASE_PLEASE_TOKEN scope/expiry."
exit 1
fi
+26
View File
@@ -30,6 +30,29 @@ jobs:
runs-on: ubuntu-latest
timeout-minutes: 10
# The .picpeak restore suites gate their real-Postgres cases behind
# PICPEAK_PG_TEST_URL and `describe.skip` themselves out when it is
# unset — so until now they never ran here. That hid the half that
# matters: sequence resync, operator/role preservation across a
# cross-instance restore, and (with #1041) whether a SQLite-shaped
# row actually lands in Postgres with the right STORED VALUES rather
# than merely not throwing. Everything else in the suite still runs
# on SQLite; this service only un-gates those cases.
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: picpeak
POSTGRES_PASSWORD: testpass
POSTGRES_DB: picpeak_test
options: >-
--health-cmd "pg_isready -U picpeak -d picpeak_test"
--health-interval 2s
--health-timeout 2s
--health-retries 30
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
@@ -52,6 +75,9 @@ jobs:
# The S3 path itself is covered separately by the integration
# suite when MinIO is provisioned.
SKIP_S3_TESTS: 'true'
# Un-gates the real-Postgres cases in the .picpeak restore suites
# (see the `services:` note above). Absent it they silently skip.
PICPEAK_PG_TEST_URL: 'postgres://picpeak:testpass@127.0.0.1:5432/picpeak_test'
run: |
# Excluded suites — fail on upstream/beta too, tracked
# separately as test-infra debt:
+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.8"}
{".":"3.46.0"}
+91
View File
@@ -5,6 +5,97 @@ 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.0](https://github.com/PicPeak/picpeak/compare/v3.45.16...v3.46.0) (2026-08-16)
### Features
* **backup:** open sqlite → pg .picpeak restore as the supported upgrade direction ([#1041](https://github.com/PicPeak/picpeak/issues/1041)) ([#1059](https://github.com/PicPeak/picpeak/issues/1059)) ([980378a](https://github.com/PicPeak/picpeak/commit/980378a17ba873d0e2f3d76048dacb3b8d7a4eb2))
### Bug Fixes
* **pdf:** RFC 6266-encode Content-Disposition on quote/invoice PDFs ([#1024](https://github.com/PicPeak/picpeak/issues/1024)) ([#1062](https://github.com/PicPeak/picpeak/issues/1062)) ([376311c](https://github.com/PicPeak/picpeak/commit/376311cb9091ff1726e8b383312f22c607dcc8a0))
* **storage:** add S3 client timeouts so a dropped connection can't wedge uploads ([#1049](https://github.com/PicPeak/picpeak/issues/1049)) ([#1054](https://github.com/PicPeak/picpeak/issues/1054)) ([88fa3c5](https://github.com/PicPeak/picpeak/commit/88fa3c52973fa122f8d4e7b21ba1ffc89f9f9c2e))
## [3.45.16](https://github.com/PicPeak/picpeak/compare/v3.45.15...v3.45.16) (2026-08-13)
### Bug Fixes
* **docker:** default NODE_ENV=production so non-compose deploys don't fall back to SQLite ([#1038](https://github.com/PicPeak/picpeak/issues/1038)) ([#1040](https://github.com/PicPeak/picpeak/issues/1040)) ([9003b34](https://github.com/PicPeak/picpeak/commit/9003b34c8a0396cd28906f089aef33f38a23ffb7))
* **events:** make event_date/expires_at nullable on SQLite ([#1029](https://github.com/PicPeak/picpeak/issues/1029)) ([#1036](https://github.com/PicPeak/picpeak/issues/1036)) ([fb3d0b0](https://github.com/PicPeak/picpeak/commit/fb3d0b08b2dc34f7e7dab7da754a3522c52a9eb1))
* **feedback:** persist guest feedback settings, unshadow the guest route ([#1030](https://github.com/PicPeak/picpeak/issues/1030)) ([#1032](https://github.com/PicPeak/picpeak/issues/1032)) ([de459c7](https://github.com/PicPeak/picpeak/commit/de459c701f28532ca53d52773b02de44c9978073))
* **gallery:** coerce SQLite 0/1 booleans in the guest surface ([#1028](https://github.com/PicPeak/picpeak/issues/1028)) ([#1037](https://github.com/PicPeak/picpeak/issues/1037)) ([8b6cd3c](https://github.com/PicPeak/picpeak/commit/8b6cd3c74f2aeb5d38ebfeee04bbc211d6fa2c0c))
## [3.45.15](https://github.com/PicPeak/picpeak/compare/v3.45.14...v3.45.15) (2026-08-10)
### Bug Fixes
* **deps:** bump nanoid and js-yaml out of two HIGH advisories (stable) ([#1014](https://github.com/PicPeak/picpeak/issues/1014)) ([cee0a38](https://github.com/PicPeak/picpeak/commit/cee0a380a6faf2bb0a5c802057ba3140670840d2))
* **slideshow:** stop "no crop" fit letterboxing a pre-cropped frame (stable) ([#1015](https://github.com/PicPeak/picpeak/issues/1015)) ([#1019](https://github.com/PicPeak/picpeak/issues/1019)) ([2bdb120](https://github.com/PicPeak/picpeak/commit/2bdb1204fe61a9b6cd704b35ccfd39efa15ed118))
## [3.45.14](https://github.com/PicPeak/picpeak/compare/v3.45.13...v3.45.14) (2026-08-04)
### Bug Fixes
* **deps:** bump ip-address, brace-expansion and postcss for open CVEs (stable) ([#988](https://github.com/PicPeak/picpeak/issues/988)) ([0fe5792](https://github.com/PicPeak/picpeak/commit/0fe5792a7d30bd948d6430642ca0bec35ddc2ca6))
* **security:** vet the destination project when linking a deal (stable) ([#992](https://github.com/PicPeak/picpeak/issues/992)) ([bf9bd76](https://github.com/PicPeak/picpeak/commit/bf9bd762783a2a675f0a6fcd965addf0f47cec57))
## [3.45.13](https://github.com/PicPeak/picpeak/compare/v3.45.12...v3.45.13) (2026-08-03)
### Bug Fixes
* **auth:** fail closed when the adminAuth roles join errors (stable) ([#975](https://github.com/PicPeak/picpeak/issues/975)) ([cc49f69](https://github.com/PicPeak/picpeak/commit/cc49f6997ac54c3e25d5562721c446b7dac7f074))
* **projects:** stop the cockpit offering email controls the API rejects (stable) ([#977](https://github.com/PicPeak/picpeak/issues/977)) ([2d0e6ab](https://github.com/PicPeak/picpeak/commit/2d0e6ab2dca84cf74c6c6b5c40ecae5c5cde814c))
* **security:** backup/restore hardening — public-dir DB dump, restore path allowlist, gunzip bound, manifest keying (stable) ([#962](https://github.com/PicPeak/picpeak/issues/962)) ([3b88036](https://github.com/PicPeak/picpeak/commit/3b88036fda871b3a1ca2e933c39fa96e37950fe6))
* **security:** bound inbound-mail resources, redact secrets from logs (stable) ([#965](https://github.com/PicPeak/picpeak/issues/965)) ([ccab902](https://github.com/PicPeak/picpeak/commit/ccab9024d4ef2f556169bbca8c6bba4801afe3a0))
* **security:** enforce event ownership on the v1 API surface (GHSA-9697) (stable) ([#963](https://github.com/PicPeak/picpeak/issues/963)) ([4e99897](https://github.com/PicPeak/picpeak/commit/4e9989731390f9c067b048f4fa56ed3bf8ec472d))
* **security:** enforce project ownership on project + project-email routes (stable) ([#966](https://github.com/PicPeak/picpeak/issues/966)) ([fecc18c](https://github.com/PicPeak/picpeak/commit/fecc18cbc837507bf30dd7502786de4e067855a5))
* **security:** escape brand tokens, block tracker redirects, trim logo diagnostic (stable) ([#967](https://github.com/PicPeak/picpeak/issues/967)) ([7f27e67](https://github.com/PicPeak/picpeak/commit/7f27e6771f666a40ec0581dc7702be3a1de8330d))
* **security:** scope dashboard stats/analytics/activity to the caller's events (stable) ([#964](https://github.com/PicPeak/picpeak/issues/964)) ([11f9f58](https://github.com/PicPeak/picpeak/commit/11f9f584ded5f777a61dc2e1e637d478a61ac377))
## [3.45.12](https://github.com/PicPeak/picpeak/compare/v3.45.11...v3.45.12) (2026-08-02)
### Bug Fixes
* **security:** authz/ownership gaps (token binding, auth revocation, feedback/customer ownership, token logging) (stable) ([#951](https://github.com/PicPeak/picpeak/issues/951)) ([5d5db4e](https://github.com/PicPeak/picpeak/commit/5d5db4e766eee23a6678b399cdb9cc449fcea198))
* **security:** neutralize spreadsheet formulas in all CSV/export cell-writers (CSV injection cluster) ([#949](https://github.com/PicPeak/picpeak/issues/949)) ([e5dccf1](https://github.com/PicPeak/picpeak/commit/e5dccf166419bb571b052990aada14801e16ac79))
* **security:** redact gallery share tokens from analytics tracking (GHSA-7m6c) (stable) ([#953](https://github.com/PicPeak/picpeak/issues/953)) ([2c7b5df](https://github.com/PicPeak/picpeak/commit/2c7b5dfd020ac1fb2acdb7667998b2f373ce99d9))
* **security:** unauth share_token leak (HIGH) + restore path-traversal, logo file-read (stable) ([#947](https://github.com/PicPeak/picpeak/issues/947)) ([bfafece](https://github.com/PicPeak/picpeak/commit/bfafecedc755790374565281287b9777ae0f5315))
## [3.45.11](https://github.com/PicPeak/picpeak/compare/v3.45.10...v3.45.11) (2026-08-01)
### Bug Fixes
* **security:** block guest access to hidden/client-only photos across bulk + secure routes (stable) ([#940](https://github.com/PicPeak/picpeak/issues/940)) ([34a7b1c](https://github.com/PicPeak/picpeak/commit/34a7b1c0137cf4cba53f17270f619ce9755c4c98))
* **security:** bump sanitize-html to 2.17.5 (CVE-2026-53606) (stable) ([#938](https://github.com/PicPeak/picpeak/issues/938)) ([7419c68](https://github.com/PicPeak/picpeak/commit/7419c683375d12650c448caa47ffe0444a7e1458))
* **security:** close authorization/ownership gaps (token scope, mass-assignment, category hero, project docs) (stable) ([#944](https://github.com/PicPeak/picpeak/issues/944)) ([2462ba6](https://github.com/PicPeak/picpeak/commit/2462ba6897c93b3f0834d60cc2e6827a45487062))
* **security:** resolve DNS before vetting external hostnames (SSRF cluster) (stable) ([#942](https://github.com/PicPeak/picpeak/issues/942)) ([90275f8](https://github.com/PicPeak/picpeak/commit/90275f88e9af523ba3cc254cef2ca6f48a2a4129))
* **uploads:** prevent cross-photo contamination from filename collisions and non-atomic writes ([#931](https://github.com/PicPeak/picpeak/issues/931)) (stable) ([#934](https://github.com/PicPeak/picpeak/issues/934)) ([fc99e2b](https://github.com/PicPeak/picpeak/commit/fc99e2b233b4a7c81c410f7d557e7dff270437bd))
## [3.45.10](https://github.com/PicPeak/picpeak/compare/v3.45.9...v3.45.10) (2026-07-30)
### Bug Fixes
* **admin:** expose view/download counters in the admin photos list ([#895](https://github.com/PicPeak/picpeak/issues/895) follow-up) (stable) ([#915](https://github.com/PicPeak/picpeak/issues/915)) ([a27d19b](https://github.com/PicPeak/picpeak/commit/a27d19b4d147c33166b47a6bac12b8d1be32daf1))
* **admin:** serve videos with their real MIME type in the admin photo view ([#908](https://github.com/PicPeak/picpeak/issues/908)) (stable) ([#911](https://github.com/PicPeak/picpeak/issues/911)) ([d68d84e](https://github.com/PicPeak/picpeak/commit/d68d84e5c8cfcd123c95e474d2b87d153764f710))
* **admin:** stop marking events expired up to 24h early ([#909](https://github.com/PicPeak/picpeak/issues/909)) (stable) ([#917](https://github.com/PicPeak/picpeak/issues/917)) ([6891769](https://github.com/PicPeak/picpeak/commit/6891769124f77d5af1bbe8eb0932a86753d89e7d))
* **security:** close GHSA-g94x (cross-gallery photo read) + GHSA-pv6w (admin DB export) (stable) ([#925](https://github.com/PicPeak/picpeak/issues/925)) ([60cbda5](https://github.com/PicPeak/picpeak/commit/60cbda5b2228e0bddf5356eae279d9e7916e9ac2))
## [3.45.9](https://github.com/PicPeak/picpeak/compare/v3.45.8...v3.45.9) (2026-07-29)
### Bug Fixes
* **analytics:** make per-photo view/download counters actually count ([#895](https://github.com/PicPeak/picpeak/issues/895)) (stable) ([#905](https://github.com/PicPeak/picpeak/issues/905)) ([90b589a](https://github.com/PicPeak/picpeak/commit/90b589a88e4c56ccac6dba86c48a604f2abcc008))
## [3.45.8](https://github.com/PicPeak/picpeak/compare/v3.45.7...v3.45.8) (2026-07-29)
+7 -2
View File
@@ -111,10 +111,15 @@ docker compose up -d
On first start with no `ADMIN_PASSWORD` set, PicPeak has **no admin account yet** and greets you with an in-browser setup screen — no credentials in `.env`:
1. Open **http://localhost:3000/admin** — you'll be redirected to `/setup`.
2. Grab the **one-time setup token** from the backend logs (it's also saved to `data/SETUP_TOKEN`):
2. Read the **one-time setup token** from the 0600 file the backend writes it to
(it is deliberately *not* printed to the logs — that would leave a live
bootstrap credential in `docker logs`):
```bash
docker compose logs backend | grep -i "setup token"
docker compose exec backend cat /app/data/SETUP_TOKEN
```
It is bind-mounted, so `sudo cat data/SETUP_TOKEN` on the host works too. Only
if that file could not be written does the backend fall back to logging the
token (`docker compose logs backend | grep -i "setup token"`).
3. Paste the token, set your admin **email + password**, and you're in. The token is single-use, and the setup screen closes permanently once an admin exists.
> Prefer the old behaviour? Set `ADMIN_PASSWORD` in `.env` and PicPeak auto-creates the admin on first boot instead (credentials written to `data/ADMIN_CREDENTIALS.txt`).
+4 -2
View File
@@ -170,10 +170,12 @@ If you installed with `picpeak-setup.sh` and gave an `--admin-password`, your ad
If you started PicPeak **without** setting `ADMIN_PASSWORD` (e.g. a plain `docker compose up`), there's **no admin yet** and you create it in the browser:
1. Open `http://your-server:3000/admin` — you'll land on a setup screen.
2. Get the **one-time setup token** from the backend logs (also saved to `data/SETUP_TOKEN`):
2. Read the **one-time setup token** from the 0600 file the backend writes it to
(it is not logged — that would leave a live credential in `docker logs`):
```bash
docker compose logs backend | grep -i "setup token"
docker compose exec backend cat /app/data/SETUP_TOKEN
```
Only if that write fails does the backend log the token instead.
3. Paste it, set your admin email + password. The token is single-use and the screen closes once an admin exists.
## 🌐 Access Methods
+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,111 @@
/**
* The roles-join fallback in adminAuth fabricates `role_name = 'super_admin'`
* to keep existing sessions working across the RBAC upgrade window. The catch
* around it used to be unconditional, so ANY transient database failure —
* connection reset, deadlock, statement timeout, pool exhaustion — took the
* same branch and handed the caller super_admin for the duration of the fault.
*
* `roleName` is the sole discriminator for every ownership check (ownership.js,
* adminProjects, adminUsers, adminApiTokens, projectService, ...), so that
* inverted the whole authorization model rather than failing the request.
* Issue #968. Same treatment apiTokenAuth already got for the v1 surface.
*/
const jwt = require('jsonwebtoken');
jest.mock('../../src/utils/tokenRevocation', () => ({ isTokenRevoked: jest.fn().mockResolvedValue(false) }));
jest.mock('../../src/utils/logger', () => ({ warn: jest.fn(), error: jest.fn(), debug: jest.fn(), info: jest.fn() }));
// The joined query throws whatever the test stages; the role-less fallback
// query (no .leftJoin) always succeeds, which is what made the original bug
// reachable — it is the cheaper single-table read.
// `mock`-prefixed so jest's module-factory hoisting allows the reference.
let mockJoinError = null;
const mockAdminRow = { id: 7, username: 'scoped', email: 's@example.com', password_changed_at: null };
jest.mock('../../src/database/db', () => ({
db: () => ({
_joined: false,
leftJoin() { this._joined = true; return this; },
where() { return this; },
select() { return this; },
first() {
if (this._joined && mockJoinError) return Promise.reject(mockJoinError);
return Promise.resolve({ ...mockAdminRow });
},
}),
}));
const { adminAuth } = require('../../src/middleware/auth');
const SECRET = 'test-secret-for-admin-auth-fallback';
function makeReq() {
const token = jwt.sign(
{ id: mockAdminRow.id, type: 'admin' },
SECRET,
{ algorithm: 'HS256', issuer: 'picpeak-auth' },
);
return { headers: { authorization: `Bearer ${token}` }, ip: '127.0.0.1', connection: {} };
}
function makeRes() {
return {
statusCode: null,
body: null,
status(code) { this.statusCode = code; return this; },
json(payload) { this.body = payload; return this; },
};
}
describe('adminAuth roles-join fallback (#968)', () => {
const OLD_SECRET = process.env.JWT_SECRET;
beforeAll(() => { process.env.JWT_SECRET = SECRET; });
afterAll(() => { process.env.JWT_SECRET = OLD_SECRET; });
beforeEach(() => { mockJoinError = null; });
it('grants the upgrade-window fallback only for a genuinely missing roles table', async () => {
mockJoinError = new Error('SQLITE_ERROR: no such table: roles');
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).toHaveBeenCalled();
expect(req.admin.roleName).toBe('super_admin');
});
it.each([
['connection reset', new Error('Connection terminated unexpectedly')],
['deadlock', new Error('deadlock detected')],
['pool exhaustion', new Error('Knex: Timeout acquiring a connection')],
['statement timeout', new Error('canceling statement due to statement timeout')],
])('does NOT fabricate super_admin on a transient failure (%s)', async (_label, err) => {
mockJoinError = err;
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
// Fails closed: request rejected, req.admin never populated. The specific
// status is 401 (adminAuth's blanket outer catch) — what matters is that
// the caller is not elevated and does not reach the route.
expect(next).not.toHaveBeenCalled();
expect(req.admin).toBeUndefined();
expect(res.statusCode).toBe(401);
});
it('does NOT fabricate super_admin when an unrelated table is missing', async () => {
mockJoinError = new Error('SQLITE_ERROR: no such table: admin_sessions');
const req = makeReq();
const res = makeRes();
const next = jest.fn();
await adminAuth(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(req.admin).toBeUndefined();
});
});
@@ -0,0 +1,72 @@
/**
* The roles-join fallback in apiTokenAuth grants `super_admin` (upgrade-path
* parity with adminAuth). It must therefore fire ONLY when the roles schema is
* genuinely absent — a catch-all turns any transient database failure into a
* privilege escalation that reopens GHSA-9697 for a demoted token owner.
*/
const { isMissingRolesSchema } = require('../../src/middleware/apiTokenAuth');
describe('apiTokenAuth roles-schema fallback predicate (GHSA-9697)', () => {
it('accepts a genuinely missing roles table on both engines', () => {
expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: roles'))).toBe(true);
expect(isMissingRolesSchema(
Object.assign(new Error('relation "roles" does not exist'), { code: '42P01' }),
)).toBe(true);
expect(isMissingRolesSchema(
Object.assign(new Error('column roles.name does not exist'), { code: '42703' }),
)).toBe(true);
});
it('rejects transient failures that must not elevate the caller', () => {
expect(isMissingRolesSchema(new Error('Connection terminated unexpectedly'))).toBe(false);
expect(isMissingRolesSchema(new Error('deadlock detected'))).toBe(false);
expect(isMissingRolesSchema(new Error('Knex: Timeout acquiring a connection'))).toBe(false);
expect(isMissingRolesSchema(new Error('canceling statement due to statement timeout'))).toBe(false);
expect(isMissingRolesSchema(undefined)).toBe(false);
});
it('rejects a missing-table error for an unrelated table', () => {
expect(isMissingRolesSchema(new Error('SQLITE_ERROR: no such table: api_tokens'))).toBe(false);
});
// knex prefixes the failing SQL to err.message, and that SQL always names
// `roles` on this join — so the message substring proves nothing about the
// error, and only an exact driver phrase (or a SQLSTATE) may be trusted.
// These are real knex message shapes, captured from the actual query.
describe('with knex\'s SQL prefix on the message (#968)', () => {
const withSql = (driverMessage) => new Error(
'select `roles`.`name` as `role_name` from `admin_users` '
+ 'left join `roles` on `roles`.`id` = `admin_users`.`role_id` '
+ `where \`admin_users\`.\`id\` = 1 limit 1 - ${driverMessage}`,
);
it('accepts both legitimate upgrade-window states', () => {
// pre-054: the roles table does not exist yet
expect(isMissingRolesSchema(
Object.assign(withSql('SQLITE_ERROR: no such table: roles'), { code: 'SQLITE_ERROR' }),
)).toBe(true);
// post-054, pre-057: roles exists, admin_users.role_id not added yet
expect(isMissingRolesSchema(
Object.assign(withSql('SQLITE_ERROR: no such column: admin_users.role_id'), { code: 'SQLITE_ERROR' }),
)).toBe(true);
});
it('rejects an unrelated "does not exist" fault despite the SQL naming roles', () => {
// pgbouncer transaction pooling loses a named prepared statement
// (SQLSTATE 26000). Transient — the fallback query would succeed on a
// fresh connection, so accepting this would fabricate super_admin.
expect(isMissingRolesSchema(
Object.assign(withSql('prepared statement "S_1" does not exist'), { code: '26000' }),
)).toBe(false);
// The DB role/user, not the roles table.
expect(isMissingRolesSchema(
Object.assign(withSql('role "picpeak" does not exist'), { code: '28000' }),
)).toBe(false);
expect(isMissingRolesSchema(
Object.assign(withSql('database "picpeak" does not exist'), { code: '3D000' }),
)).toBe(false);
expect(isMissingRolesSchema(withSql('Connection terminated unexpectedly'))).toBe(false);
});
});
});
@@ -0,0 +1,35 @@
/**
* Migration 167 (projects.created_by) — idempotent on re-run, reversible,
* and backfills the owner from a project's single linked event (GHSA-wrg5).
*/
const path=require('path'), fs=require('fs'), os=require('os');
process.env.NODE_ENV='test';
process.env.TEST_DATABASE_PATH=path.join(fs.mkdtempSync(path.join(os.tmpdir(),'picpeak-mig167-')),'db.sqlite');
process.env.JWT_SECRET='mig';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const mig = require('../../migrations/core/167_add_projects_created_by');
describe('migration 167', () => {
let db, cleanup;
beforeAll(async()=>{ ({db,cleanup}=await bootCrmDb()); await seedMinimal(db); },120000);
afterAll(async()=>{ if(cleanup) await cleanup(); });
it('is idempotent on re-run and reversible', async () => {
await mig.up(db); // already applied by boot; must no-op
await mig.up(db); // and again
expect(await db.schema.hasColumn('projects','created_by')).toBe(true);
await mig.down(db);
expect(await db.schema.hasColumn('projects','created_by')).toBe(false);
await mig.up(db); // re-apply cleanly
expect(await db.schema.hasColumn('projects','created_by')).toBe(true);
});
it('backfills created_by from a single linked event owner', async () => {
const p = await db('projects').insert({name:'bf',status:'active',created_at:new Date(),updated_at:new Date()}).returning('id');
const pid = p[0]?.id ?? p[0];
await db('events').insert({slug:'bf-ev',event_type:'wedding',event_name:'bf',event_date:'2026-08-01',
host_email:'h@e.com',admin_email:'a@e.com',password_hash:'x',share_token:'t1',share_link:'/g/bf-ev/t1',
created_by: 4242, project_id: pid, expires_at:new Date(Date.now()+864e5).toISOString(),
is_active:1,is_archived:0,is_draft:0,created_at:new Date().toISOString()});
await mig.up(db);
const row = await db('projects').where({id:pid}).first();
expect(row.created_by).toBe(4242);
});
});
@@ -0,0 +1,83 @@
/**
* GHSA-jhcf round 3: scoping the activity feed does nothing about the rows
* already on disk. expenseService used to pass adminId into logActivity's
* `eventId` slot, so upgraded instances carry accounting rows whose event_id
* is an ADMIN id — and the scope predicate happily matches those against a
* same-numbered event the caller owns.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-mig168-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'mig168-test-secret';
const { bootCrmDb } = require('../integration/helpers/crmDb');
const migration = require('../../migrations/core/168_fix_expense_activity_event_id');
describe('migration 168 — legacy accounting activity rows (GHSA-jhcf)', () => {
let db; let cleanup;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('re-attributes the admin id and clears event_id, leaving real rows alone', async () => {
await db('activity_logs').insert([
// Legacy shape: event_id is really admin #7, no actor recorded.
{
activity_type: 'expense_created',
actor_type: 'system',
actor_id: null,
event_id: 7,
metadata: JSON.stringify({ expenseId: 1 }),
created_at: new Date().toISOString(),
},
{
activity_type: 'incoming_invoice_captured',
actor_type: 'system',
actor_id: null,
event_id: 9,
metadata: JSON.stringify({ inboundDocumentId: 2 }),
created_at: new Date().toISOString(),
},
// A genuine event-scoped row from another subsystem must survive intact.
{
activity_type: 'photo_uploaded',
actor_type: 'admin',
actor_id: 3,
event_id: 7,
metadata: JSON.stringify({}),
created_at: new Date().toISOString(),
},
]);
await migration.up(db);
const expense = await db('activity_logs').where({ activity_type: 'expense_created' }).first();
expect(expense.event_id == null).toBe(true);
expect(Number(expense.actor_id)).toBe(7);
expect(expense.actor_type).toBe('admin');
const captured = await db('activity_logs').where({ activity_type: 'incoming_invoice_captured' }).first();
expect(captured.event_id == null).toBe(true);
expect(Number(captured.actor_id)).toBe(9);
const photo = await db('activity_logs').where({ activity_type: 'photo_uploaded' }).first();
expect(Number(photo.event_id)).toBe(7);
expect(Number(photo.actor_id)).toBe(3);
});
it('is idempotent on re-run', async () => {
await expect(migration.up(db)).resolves.toBeUndefined();
const expense = await db('activity_logs').where({ activity_type: 'expense_created' }).first();
expect(Number(expense.actor_id)).toBe(7);
expect(expense.event_id == null).toBe(true);
});
});
@@ -0,0 +1,238 @@
/**
* Admin photo view route Content-Type (#908).
*
* The route built `image/<ext>` from the filename, producing invalid
* types like image/mp4 for videos. AdminAuthenticatedVideo fetches this
* URL into a blob whose type inherits the header, and browsers refuse to
* play a <video> blob labeled image/* — blank/grey admin video preview.
*
* Pins (incl. external-review hardening):
* - the header is ALWAYS image/* or video/*: a stored non-media MIME
* (chunked uploads store the client-sent type unvalidated) is never
* echoed — text/html inline under the app origin would be XSS
* - stored video/ MIME wins; MIME-less videos map from the extension
* (.mov → video/quicktime), unknown video extensions get video/mp4
* - images IGNORE the stored MIME (migration 039 backfilled image/jpeg
* onto every legacy row, PNGs included) and use the extension,
* normalized (jpg → image/jpeg); extensionless files get image/jpeg
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admin-ct-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'admin-ct-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-admin-ct-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'admin-ct-test-event';
describe('admin photo view Content-Type (#908)', () => {
let db;
let cleanup;
let app;
let eventId;
let adminToken;
const addPhoto = async (filename, extra = {}) => {
const dir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), Buffer.from(`bytes-${filename}`));
const r = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
...extra,
}).returning('id');
return r[0]?.id ?? r[0];
};
const getPhotoRes = (photoId) => request(app)
.get(`/api/admin/photos/${eventId}/photo/${photoId}`)
.set('Authorization', `Bearer ${adminToken}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Admin CT Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'admin-ct-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'admin-ct-admin',
email: 'admin-ct-admin@example.com',
password_hash: await bcrypt.hash('AdminCt123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'admin-ct-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
app = express();
app.use(express.json());
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('serves a video with its stored mime_type, not image/<ext>', async () => {
const id = await addPhoto('clip.mp4', { media_type: 'video', mime_type: 'video/mp4' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
});
it('maps MIME-less videos from their extension (.mov → video/quicktime)', async () => {
const id = await addPhoto('clip-nomime.mov', { media_type: 'video' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/quicktime');
});
it('falls back to video/mp4 for a video with an unknown extension', async () => {
const id = await addPhoto('clip-unknown.xyz', { media_type: 'video' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
});
it('rejects malformed video/ MIME values that would break setHeader', async () => {
// Header-invalid chars in the stored value must not 500 the route —
// fall back to the extension map instead.
const id = await addPhoto('crlf.mp4', {
media_type: 'video',
mime_type: 'video/mp4\r\nX-Evil: 1',
});
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('video/mp4');
expect(res.headers['x-evil']).toBeUndefined();
const bare = await addPhoto('bare.webm', { media_type: 'video', mime_type: 'video/' });
const res2 = await getPhotoRes(bare);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('video/webm');
});
it('preserves an auto-imported avif via the safe stored-MIME allowlist', async () => {
// .avif isn't in EXTENSION_TO_MIME; s3AutoImporter stores image/avif.
// Map-only would mislabel it image/jpeg — the allowlist keeps it.
const id = await addPhoto('imported.avif', { mime_type: 'image/avif' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/avif');
});
it('preserves other importer raster types too (apng, x-icon)', async () => {
const apng = await addPhoto('anim.apng', { mime_type: 'image/apng' });
expect((await getPhotoRes(apng)).headers['content-type']).toBe('image/apng');
const ico = await addPhoto('fav.ico', { mime_type: 'image/x-icon' });
expect((await getPhotoRes(ico)).headers['content-type']).toBe('image/x-icon');
});
it('does NOT honor a stored scriptable image type (image/svg+xml)', async () => {
// svg is inline-scriptable and must never be echoed — allowlist excludes it.
const id = await addPhoto('vector.svg', { mime_type: 'image/svg+xml' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
it('never echoes a stored non-media MIME type (inline XSS guard)', async () => {
const id = await addPhoto('evil.png', { mime_type: 'text/html' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('ignores the migration-039 image/jpeg backfill on legacy PNG rows', async () => {
const id = await addPhoto('legacy.png', { mime_type: 'image/jpeg' });
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('normalizes jpg to the canonical image/jpeg', async () => {
const id = await addPhoto('shot.jpg');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
it('keeps the extension fallback for images without a stored mime_type', async () => {
const id = await addPhoto('shot.png');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/png');
});
it('handles Object.prototype key extensions without a 500 (.constructor)', async () => {
// The extension-to-MIME lookup must be own-property only — a raw
// index access returns an inherited function for these keys and the
// downstream startsWith throws. Serve image/jpeg instead of 500.
const id = await addPhoto('payload.constructor');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
const id2 = await addPhoto('payload.__proto__', { media_type: 'video' });
const res2 = await getPhotoRes(id2);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('video/mp4');
});
it('does not synthesize types from unmapped image extensions', async () => {
// Raw interpolation would produce image/svg+xml (scriptable inline)
// or arbitrary strings from client-controlled filenames — the shared
// map is the allowlist, everything else is served as image/jpeg.
const svg = await addPhoto('vector.svg+xml');
const res = await getPhotoRes(svg);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
const weird = await addPhoto('weird.xyz');
const res2 = await getPhotoRes(weird);
expect(res2.status).toBe(200);
expect(res2.headers['content-type']).toBe('image/jpeg');
});
it('extensionless files get image/jpeg, never a bare image/', async () => {
const id = await addPhoto('noext');
const res = await getPhotoRes(id);
expect(res.status).toBe(200);
expect(res.headers['content-type']).toBe('image/jpeg');
});
});
@@ -0,0 +1,211 @@
/**
* Authorization / ownership gaps (GHSA permission cluster):
* - jm7j: API-token list must scope to the caller (non-super sees only own)
* - gprq: API-token revoke must be owner-or-super_admin
* - 3rqx: event update must not mass-assign identity/secret columns
* - j2f4: category hero must belong to that category
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-authz-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'authz-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-authz-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const {
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken,
} = require('../integration/helpers/crmDb');
describe('authorization / ownership gaps', () => {
let db; let cleanup; let app;
let superId; let superTok; let adminId; let adminTok;
const grantPermissionToRole = async (roleName, permName) => {
const role = await db('roles').where({ name: roleName }).first();
const perm = await db('permissions').where({ name: permName }).first();
const exists = await db('role_permissions')
.where({ role_id: role.id, permission_id: perm.id }).first();
if (!exists) {
await db('role_permissions').insert({ role_id: role.id, permission_id: perm.id });
}
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
({ adminId: superId } = await seedMinimal(db));
await assignAdminRole(db, superId, 'super_admin');
superTok = mintAdminToken(superId);
const pass = await bcrypt.hash('x', 4);
const ins = await db('admin_users').insert({
username: 'plain-admin', email: 'plain@example.com',
password_hash: pass, must_change_password: false, created_at: new Date(),
}).returning('id');
adminId = ins[0]?.id ?? ins[0];
await assignAdminRole(db, adminId, 'admin');
// Grant settings.edit to the admin role BEFORE any request populates the
// 60s permission cache, so the revoke test exercises the ownership check
// (404) rather than the missing-permission gate (403). This models a
// custom role that carries settings.edit — the scenario GHSA-gprq needs.
await grantPermissionToRole('admin', 'settings.edit');
adminTok = mintAdminToken(adminId);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/api-tokens', require('../../src/routes/adminApiTokens'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/admin/categories', require('../../src/routes/adminCategories'));
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
res.status(err.statusCode || err.status || 500).json({ error: err.message, code: err.code });
});
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
describe('API tokens (jm7j / gprq)', () => {
let superTokenId;
beforeAll(async () => {
const res = await auth(request(app).post('/api/admin/api-tokens'), superTok)
.send({ name: 'super-token', scopes: ['read'] });
expect(res.status).toBe(201);
superTokenId = res.body.id;
});
it('non-super admin does not see another admin\'s tokens in the list', async () => {
const res = await auth(request(app).get('/api/admin/api-tokens'), adminTok);
expect(res.status).toBe(200);
expect(res.body.find((t) => t.id === superTokenId)).toBeUndefined();
});
it('super_admin sees all tokens', async () => {
const res = await auth(request(app).get('/api/admin/api-tokens'), superTok);
expect(res.status).toBe(200);
expect(res.body.find((t) => t.id === superTokenId)).toBeDefined();
});
it('a non-owner (with settings.edit) cannot revoke another admin\'s token', async () => {
const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), adminTok);
expect(res.status).toBe(404);
const row = await db('api_tokens').where({ id: superTokenId }).first();
expect(row.revoked_at).toBeFalsy();
});
it('the owner can revoke their own token', async () => {
const res = await auth(request(app).delete(`/api/admin/api-tokens/${superTokenId}`), superTok);
expect(res.status).toBe(200);
});
});
describe('event update mass-assignment (3rqx)', () => {
it('ignores identity/secret columns in the request body', async () => {
const seedShareToken = 'orig-share-token';
const ins = await db('events').insert({
slug: 'authz-mass-assign', event_type: 'wedding', event_name: 'Before',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'orig-hash', share_link: '/gallery/authz/share', share_token: seedShareToken, expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const eventId = ins[0]?.id ?? ins[0];
const res = await auth(request(app).put(`/api/admin/events/${eventId}`), superTok).send({
event_name: 'After',
created_by: 99999,
slug: 'hijacked-slug',
share_token: 'hijacked-token',
password_hash: 'hijacked-hash',
is_archived: 1,
archive_path: '/hijacked/archive/path',
hero_logo_path: '/etc/passwd',
is_draft: 1,
project_id: 99999,
// Case-variant keys — SQLite matches columns case-insensitively.
Password_Hash: 'case-hijack-hash',
Created_By: 88888,
});
expect(res.status).toBe(200);
const row = await db('events').where({ id: eventId }).first();
expect(row.event_name).toBe('After'); // legit field applied
expect(row.created_by).toBe(superId); // ownership untouched (+ case-variant)
expect(row.slug).toBe('authz-mass-assign'); // routing identity untouched
expect(row.share_token).toBe(seedShareToken); // secret untouched
expect(row.password_hash).toBe('orig-hash'); // secret untouched (+ case-variant)
expect(row.is_archived).toBeFalsy(); // archive lifecycle untouched
expect(row.archive_path).toBeFalsy(); // forged archive path rejected
expect(row.hero_logo_path).toBeFalsy(); // fs.unlink primitive blocked
expect(row.is_draft).toBeFalsy(); // publish workflow not bypassed
expect(row.project_id).toBeFalsy(); // server-managed relationship untouched
});
it('returns 200 (no-op) when the body contains only protected fields', async () => {
const ins = await db('events').insert({
slug: 'authz-empty-update', event_type: 'wedding', event_name: 'Keep',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: '/gallery/authz-empty/share', share_token: 'authz-empty-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const id = ins[0]?.id ?? ins[0];
// Body reduces to {} after the denylist — must not 500 (Knex rejects
// .update({})).
const res = await auth(request(app).put(`/api/admin/events/${id}`), superTok)
.send({ created_by: 1, slug: 'x', is_archived: 1 });
expect(res.status).toBe(200);
const row = await db('events').where({ id }).first();
expect(row.event_name).toBe('Keep');
});
});
describe('category hero cross-category (j2f4)', () => {
it('rejects a hero photo that is not in the category', async () => {
const evIns = await db('events').insert({
slug: 'authz-cat', event_type: 'wedding', event_name: 'Cat Event',
event_date: '2026-08-01', host_email: 'h@example.com', admin_email: 'a@example.com',
password_hash: 'x', share_link: '/gallery/authz-cat/share', share_token: 'authz-cat-share', expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const evId = evIns[0]?.id ?? evIns[0];
const mkCat = async (name) => {
const c = await db('photo_categories').insert({
event_id: evId, name, slug: name.toLowerCase(), created_at: new Date().toISOString(),
}).returning('id');
return c[0]?.id ?? c[0];
};
const cat1 = await mkCat('Cat1');
const cat2 = await mkCat('Cat2');
const pIns = await db('photos').insert({
event_id: evId, filename: 'p.jpg', path: 'authz-cat/p.jpg', type: 'individual',
category_id: cat1, uploaded_at: new Date().toISOString(),
}).returning('id');
const photoInCat1 = pIns[0]?.id ?? pIns[0];
// Pointing cat2's hero at a photo that lives in cat1 must be refused.
const bad = await auth(request(app).put(`/api/admin/categories/${cat2}/hero`), superTok)
.send({ hero_photo_id: photoInCat1 });
expect(bad.status).toBe(404);
// The photo's own category accepts it.
const ok = await auth(request(app).put(`/api/admin/categories/${cat1}/hero`), superTok)
.send({ hero_photo_id: photoInCat1 });
expect(ok.status).toBe(200);
});
});
});
@@ -0,0 +1,95 @@
/**
* Full-instance export is super_admin only (GHSA-pv6w-rj34-wj9v).
*
* GET /api/admin/backup/picpeak/export dumps every table unredacted (bcrypt
* hashes, 2FA, SMTP/SSO/WhatsApp/webhook/S3 secrets). It was gated only by
* requirePermission('backup.create'), which the built-in `admin` role holds —
* so any non-super_admin admin could download the whole database. Pins that
* `admin` now gets 403 and `super_admin` passes the gate.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bkexport-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bkexport-test-secret';
// The export otherwise walks the whole DB and writes a zip — stub it so the
// super_admin happy path is fast and deterministic; the gate is what's tested.
// The route deletes path.dirname(filePath) recursively after download, so the
// stub MUST live in its own dir — a bare os.tmpdir() file would make the route
// wipe the whole temp root (and other jest workers' DB files).
const mockExportDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-export-stub-'));
const mockExportPath = path.join(mockExportDir, 'export.picpeak');
fs.writeFileSync(mockExportPath, 'stub');
jest.mock('../../src/services/picpeakExportService', () => ({
createPicpeak: jest.fn(async () => ({ filePath: mockExportPath })),
}));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('backup export super_admin gate (GHSA-pv6w)', () => {
let db;
let cleanup;
let app;
let adminToken; let superToken;
const mkUser = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
return jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
adminToken = await mkUser('limited-admin', 'admin');
superToken = await mkUser('root-admin', 'super_admin');
app = express();
app.use(express.json());
app.use('/api/admin/backup', require('../../src/routes/adminBackup'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
fs.rmSync(mockExportDir, { recursive: true, force: true });
});
it('denies the built-in admin role (was: full DB dump)', async () => {
const res = await request(app)
.get('/api/admin/backup/picpeak/export')
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(403);
});
it('allows super_admin', async () => {
const res = await request(app)
.get('/api/admin/backup/picpeak/export')
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).not.toBe(403);
expect(res.status).toBeLessThan(500);
});
});
@@ -0,0 +1,196 @@
/**
* Dashboard endpoints must not leak other admins' data to event-scoped
* editors — GHSA-c2jj (/stats), GHSA-gqx7 (/analytics), GHSA-jhcf (/activity).
*
* All three are gated only by `analytics.view`, which the `editor` role holds.
* But the events LIST restricts editors to their own rows
* (adminEvents/crud.js: roleName === 'editor' → created_by = admin.id), so an
* editor saw instance-wide totals — and, via /analytics topGalleries, other
* admins' gallery names and SLUGS (the public gallery URL component) — for
* events invisible to them everywhere else.
*
* Scoping deliberately keys on `editor` to mirror the events list exactly, so
* the `admin` role's dashboard is unchanged.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dashscope-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dashscope-test-secret';
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('dashboard scoping (GHSA-c2jj / gqx7 / jhcf)', () => {
let db; let cleanup; let app;
let editorToken; let superToken;
let ownEventId; let foreignEventId;
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
const token = jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
return { id, token };
};
const mkEvent = async (slug, createdBy) => {
const r = await db('events').insert({
slug,
event_type: 'wedding',
event_name: `${slug}-name`,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: `tok-${slug}`,
share_link: `/gallery/${slug}/tok-${slug}`,
created_by: createdBy,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const editor = await mkAdmin('scoped-editor', 'editor');
const sup = await mkAdmin('root-admin', 'super_admin');
editorToken = editor.token;
superToken = sup.token;
ownEventId = await mkEvent('own-gallery', editor.id);
foreignEventId = await mkEvent('foreign-gallery', sup.id);
// One photo + one view per event so the aggregates are non-zero.
for (const [eventId, name] of [[ownEventId, 'own'], [foreignEventId, 'foreign']]) {
await db('photos').insert({
event_id: eventId,
filename: `${name}.jpg`,
path: `events/active/${name}.jpg`,
type: 'individual',
size_bytes: 1000,
uploaded_at: new Date().toISOString(),
});
await db('access_logs').insert({
event_id: eventId,
action: 'view',
ip_address: `10.0.0.${eventId}`,
user_agent: 'Mozilla/5.0',
timestamp: new Date().toISOString(),
});
await db('activity_logs').insert({
activity_type: 'photo_viewed',
actor_type: 'admin',
actor_name: `${name}-actor`,
event_id: eventId,
created_at: new Date().toISOString(),
});
}
app = express();
app.use(express.json());
app.use('/api/admin/dashboard', require('../../src/routes/adminDashboard'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('/stats counts only the editor\'s own events and photos', async () => {
const res = await request(app)
.get('/api/admin/dashboard/stats')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
expect(Number(res.body.totalEvents)).toBe(1);
expect(Number(res.body.totalPhotos)).toBe(1);
expect(Number(res.body.storageUsed)).toBe(1000);
});
it('/analytics does not expose a foreign gallery name or slug', async () => {
const res = await request(app)
.get('/api/admin/dashboard/analytics?days=7')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const body = JSON.stringify(res.body);
expect(body).not.toContain('foreign-gallery');
expect(body).not.toContain('foreign-gallery-name');
expect(res.body.topGalleries.map((g) => g.slug)).toEqual(['own-gallery']);
});
it('/activity does not surface a foreign event\'s entries', async () => {
const res = await request(app)
.get('/api/admin/dashboard/activity')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const actors = res.body.map((a) => a.actorName);
expect(actors).toContain('own-actor');
expect(actors).not.toContain('foreign-actor');
});
it('leaves super_admin unscoped across all three', async () => {
const stats = await request(app)
.get('/api/admin/dashboard/stats')
.set('Authorization', `Bearer ${superToken}`);
expect(Number(stats.body.totalEvents)).toBe(2);
const analytics = await request(app)
.get('/api/admin/dashboard/analytics?days=7')
.set('Authorization', `Bearer ${superToken}`);
expect(analytics.body.topGalleries.map((g) => g.slug).sort())
.toEqual(['foreign-gallery', 'own-gallery']);
const activity = await request(app)
.get('/api/admin/dashboard/activity')
.set('Authorization', `Bearer ${superToken}`);
expect(activity.body.map((a) => a.actorName)).toContain('foreign-actor');
});
});
/**
* Codex round 2: the /activity filter trusts `activity_logs.event_id`, but
* expenseService was passing `adminId` into logActivity's third positional
* parameter — which is `eventId`. Admin and event id sequences overlap, so a
* foreign admin's expense metadata could surface under an editor's event.
* Those writers now pass the actor instead, leaving event_id NULL.
*/
describe('activity writers do not put admin ids in event_id (GHSA-jhcf)', () => {
it('expenseService passes the actor, not adminId, as the event id', () => {
const fs2 = require('fs');
const src = fs2.readFileSync(
require('path').join(__dirname, '../../src/services/expenseService.js'), 'utf8',
);
// No logActivity call may end with a bare `, adminId)` — that slot is eventId.
const offenders = src.split('\n').filter(
(l) => l.includes('logActivity(') && /,\s*adminId\s*\)/.test(l),
);
expect(offenders).toEqual([]);
// And the actor form must actually be in use.
expect(src).toContain("{ type: 'admin', id: adminId }");
});
});
@@ -0,0 +1,120 @@
/**
* Manual database backup must not honour a caller-supplied destination
* (GHSA-jw8m-43r2-jqrm).
*
* POST /api/admin/database-backup/backup forwarded req.body straight into
* databaseBackupService.backup(), which merges options over its config:
* const { destinationPath = '/backup/database', ... } = { ...config, ...options }
* `destinationPath` is not a persistable setting (the /config allowlist only
* accepts `database_backup_*` keys), so the request body was its ONLY source.
*
* The `admin` role holds backup.create but neither settings.edit nor
* backup.restore — so it could aim a full DB dump (bcrypt hashes, gallery
* password hashes, encrypted SMTP creds) at the PUBLIC /uploads static mount
* (server.js mounts it with no auth middleware) and fetch it unauthenticated.
*
* Pins that destinationPath from the body is ignored, while the legitimate
* knobs still pass through.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-dbbackup-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'dbbackup-test-secret';
// Capture what the route hands the service; never run a real backup.
const mockBackup = jest.fn(async () => ({ success: true }));
jest.mock('../../src/services/databaseBackup', () => ({
databaseBackupService: {
get isRunning() { return false; },
backup: (...args) => mockBackup(...args),
},
}));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('manual database backup destination (GHSA-jw8m)', () => {
let db; let cleanup; let app; let adminToken;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const role = await db('roles').where({ name: 'admin' }).first();
const r = await db('admin_users').insert({
username: 'limited-admin',
email: 'limited-admin@example.com',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
adminToken = jwt.sign(
{ id, username: 'limited-admin', type: 'admin', role: 'admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' },
);
app = express();
app.use(express.json());
app.use('/api/admin/database-backup', require('../../src/routes/adminDatabaseBackup'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
beforeEach(() => mockBackup.mockClear());
it('ignores a caller-supplied destinationPath', async () => {
const res = await request(app)
.post('/api/admin/database-backup/backup')
.set('Authorization', `Bearer ${adminToken}`)
.send({ destinationPath: '/app/storage/uploads' });
expect(res.status).toBe(200);
// Give the fire-and-forget call a tick to land.
await new Promise((resolve) => setImmediate(resolve));
expect(mockBackup).toHaveBeenCalled();
const opts = mockBackup.mock.calls[0][0];
expect(opts).not.toHaveProperty('destinationPath');
expect(JSON.stringify(opts)).not.toContain('uploads');
});
it('still forwards the legitimate backup knobs', async () => {
const res = await request(app)
.post('/api/admin/database-backup/backup')
.set('Authorization', `Bearer ${adminToken}`)
.send({ compress: false, validateIntegrity: false, destinationPath: '/tmp/evil' });
expect(res.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
const opts = mockBackup.mock.calls[0][0];
expect(opts.compress).toBe(false);
expect(opts.validateIntegrity).toBe(false);
expect(opts).not.toHaveProperty('destinationPath');
});
it('omits absent knobs entirely so service/config defaults still apply', async () => {
const res = await request(app)
.post('/api/admin/database-backup/backup')
.set('Authorization', `Bearer ${adminToken}`)
.send({});
expect(res.status).toBe(200);
await new Promise((resolve) => setImmediate(resolve));
// An explicit `{compress: undefined}` would override config on spread —
// absent keys must simply not be present.
expect(mockBackup.mock.calls[0][0]).toEqual({});
});
});
@@ -0,0 +1,101 @@
/**
* GHSA-2qc2 / GHSA-32h4 / GHSA-3335 — feedback moderation, deletion, and the
* pending-moderation list are by-feedback-id (or global) and lacked ownership
* scoping, so a restricted editor could act on / enumerate feedback for events
* it does not own. super_admin keeps global access.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fbown-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'fbown-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fbown-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken } = require('../integration/helpers/crmDb');
describe('feedback ownership scoping', () => {
let db; let cleanup; let app;
let superTok; let editorTok; let editorId;
let foreignFeedbackId;
const auth = (req, tok) => req.set('Authorization', `Bearer ${tok}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
const { adminId: superId } = await seedMinimal(db);
await assignAdminRole(db, superId, 'super_admin');
superTok = mintAdminToken(superId);
const ins = await db('admin_users').insert({
username: 'editor', email: 'editor@example.com',
password_hash: await bcrypt.hash('x', 4), must_change_password: false, created_at: new Date(),
}).returning('id');
editorId = ins[0]?.id ?? ins[0];
await assignAdminRole(db, editorId, 'editor');
editorTok = mintAdminToken(editorId);
// Event owned by super_admin (NOT the editor).
const ev = await db('events').insert({
slug: 'fbown-foreign', event_type: 'wedding', event_name: 'Foreign',
event_date: '2026-08-01', host_email: 'h@e.com', admin_email: 'a@e.com',
password_hash: 'x', share_link: '/g/fbown/s', share_token: 'fbown-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, created_by: superId,
created_at: new Date().toISOString(),
}).returning('id');
const eventId = ev[0]?.id ?? ev[0];
const ph = await db('photos').insert({
event_id: eventId, filename: 'p.jpg', path: 'fbown-foreign/p.jpg', type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
const photoId = ph[0]?.id ?? ph[0];
const fb = await db('photo_feedback').insert({
photo_id: photoId, event_id: eventId, feedback_type: 'comment',
comment_text: 'hi', is_approved: 0, is_hidden: 0, created_at: new Date().toISOString(),
}).returning('id');
foreignFeedbackId = fb[0]?.id ?? fb[0];
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/admin/feedback', require('../../src/routes/adminFeedback'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('editor cannot moderate feedback on an event it does not own (404)', async () => {
const res = await auth(request(app).put(`/api/admin/feedback/feedback/${foreignFeedbackId}/approve`), editorTok);
expect(res.status).toBe(404);
const row = await db('photo_feedback').where({ id: foreignFeedbackId }).first();
expect([false, 0]).toContain(row.is_approved); // untouched
});
it('editor cannot delete foreign feedback, row survives', async () => {
const res = await auth(request(app).delete(`/api/admin/feedback/feedback/${foreignFeedbackId}`), editorTok);
// Denied either at the events.delete permission layer (editor lacks it →
// 403) or the ownership layer (404) — both must leave the row intact.
expect([403, 404]).toContain(res.status);
expect(await db('photo_feedback').where({ id: foreignFeedbackId }).first()).toBeDefined();
});
it('editor sees no foreign feedback in pending-moderation', async () => {
const res = await auth(request(app).get('/api/admin/feedback/feedback/pending-moderation'), editorTok);
expect(res.status).toBe(200);
expect(res.body.find((f) => f.id === foreignFeedbackId)).toBeUndefined();
});
it('super_admin CAN moderate and see it', async () => {
const pending = await auth(request(app).get('/api/admin/feedback/feedback/pending-moderation'), superTok);
expect(pending.body.find((f) => f.id === foreignFeedbackId)).toBeDefined();
const res = await auth(request(app).put(`/api/admin/feedback/feedback/${foreignFeedbackId}/approve`), superTok);
expect(res.status).toBe(200);
});
});
@@ -0,0 +1,87 @@
/**
* GHSA-rh8r-7x3h-36rv — the unauthenticated GET /api/gallery/resolve/:identifier
* must NOT return a gallery's secret share_token (nor the share links that
* embed it) for a bare *slug* lookup. Slugs appear in gallery URLs and are
* guessable; handing back the secret turns a known slug into share-link
* access to a no-password gallery. The token is only returned when the caller
* resolved via the token / full share link (i.e. already holds it).
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-resolve-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'resolve-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-resolve-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'resolve-test-event';
const SHARE_TOKEN = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6';
describe('GET /api/gallery/resolve/:identifier (GHSA-rh8r)', () => {
let db; let cleanup; let app;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Resolve Test',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/${SHARE_TOKEN}`,
share_token: SHARE_TOKEN,
require_password: 0, // no-password → the token IS the access credential
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
});
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('does NOT leak the share_token (or share links) for a bare slug lookup', async () => {
const res = await request(app).get(`/api/gallery/resolve/${SLUG}`);
expect(res.status).toBe(200);
expect(res.body.slug).toBe(SLUG);
expect(res.body.matchType).toBe('slug');
// The secret must be absent — and must not sneak out via the share links.
expect(res.body.token).toBeUndefined();
expect(res.body.share_link).toBeUndefined();
expect(res.body.share_url).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain(SHARE_TOKEN);
});
it('DOES return the token when the caller already resolved via the token', async () => {
const res = await request(app).get(`/api/gallery/resolve/${SHARE_TOKEN}`);
expect(res.status).toBe(200);
expect(res.body.token).toBe(SHARE_TOKEN);
expect(res.body.matchType).toMatch(/token/);
});
it('does NOT leak the token via SQL LIKE wildcards in the link_partial fallback', async () => {
// Before the escaping fix, an anonymous request of 32 underscores matched
// any share_link ending in a 32-char token (`_` = single-char wildcard),
// resolved as matchType 'link_partial', and handed back the bearer token.
// The share_token here has no underscores, so an escaped LIKE must miss.
const res = await request(app).get(`/api/gallery/resolve/${'_'.repeat(SHARE_TOKEN.length)}`);
expect(res.status).toBe(404);
expect(res.body.token).toBeUndefined();
expect(JSON.stringify(res.body)).not.toContain(SHARE_TOKEN);
});
});
@@ -0,0 +1,188 @@
/**
* SQLite boolean coercion in the guest gallery surface (#1028).
*
* SQLite stores booleans as 0/1; Postgres stores true/false. The /photos
* payload and every download guard compared strictly against `true`/`false`,
* so on SQLite:
*
* allow_downloads: 0 !== false → true (button shown while disabled)
* allow_user_uploads: 1 === true → false (button hidden while enabled)
* if (allow_downloads === false) → never fires, so ALL download endpoints
* kept serving with downloads switched off
*
* (The download-jobs route asserted on main is #858, which is beta-only —
* this branch covers the three download endpoints that exist here.)
*
* The harness runs on SQLite, so these assertions exercise the real engine
* values rather than a mock. Every test here fails on the unfixed code.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sqlite-flags-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'sqlite-flags-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-sqlite-flags-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'sqlite-flags-gallery';
describe('gallery flags survive SQLite 0/1 storage (#1028)', () => {
let db; let cleanup; let app; let eventId; let photoId;
async function setEventFlags(patch) {
await db('events').where('id', eventId).update(patch);
}
async function getPayload() {
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
expect(res.status).toBe(200);
return res.body.event;
}
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const ev = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'SQLite Flags',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/s`,
share_token: 'sqlite-flags-share',
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
// Password-free so verifyGalleryAccess takes the public path and loads
// the row with SELECT * — i.e. the raw 0/1 values, same as production.
require_password: 0,
created_at: new Date().toISOString(),
}).returning('id');
eventId = ev[0]?.id ?? ev[0];
const ph = await db('photos').insert({
event_id: eventId,
filename: 'p.jpg',
path: `${SLUG}/p.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoId = ph[0]?.id ?? ph[0];
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
test('the engine under test really is SQLite storing 0/1', async () => {
expect(['sqlite3', 'better-sqlite3']).toContain(db.client.config.client);
await setEventFlags({ allow_downloads: 0 });
const row = await db('events').where('id', eventId).first('allow_downloads');
expect(row.allow_downloads).toBe(0);
});
describe('with downloads disabled (allow_downloads = 0)', () => {
beforeAll(async () => {
await setEventFlags({ allow_downloads: 0, allow_user_uploads: 1 });
});
test('payload reports allow_downloads false (was true — header button shown)', async () => {
expect((await getPayload()).allow_downloads).toBe(false);
});
test('payload reports allow_user_uploads true (was false — upload button hidden)', async () => {
expect((await getPayload()).allow_user_uploads).toBe(true);
});
test('single-photo download is refused', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(res.status).toBe(403);
});
test('download-all is refused', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`);
expect(res.status).toBe(403);
});
test('download-selected is refused', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.send({ photo_ids: [photoId] });
expect(res.status).toBe(403);
});
});
describe('with downloads enabled (allow_downloads = 1)', () => {
beforeAll(async () => {
await setEventFlags({ allow_downloads: 1, allow_user_uploads: 0 });
});
test('payload reports allow_downloads true / allow_user_uploads false', async () => {
const event = await getPayload();
expect(event.allow_downloads).toBe(true);
expect(event.allow_user_uploads).toBe(false);
});
test('download-all is no longer refused', async () => {
const res = await request(app).get(`/api/gallery/${SLUG}/download-all`);
expect(res.status).not.toBe(403);
});
});
describe('protection flags', () => {
test('0/1 protection toggles are reported the way they are stored', async () => {
await setEventFlags({
disable_right_click: 1,
enable_devtools_protection: 1,
use_canvas_rendering: 1,
watermark_downloads: 1,
overlay_protection: 0,
});
const event = await getPayload();
expect(event.disable_right_click).toBe(true);
expect(event.enable_devtools_protection).toBe(true);
expect(event.use_canvas_rendering).toBe(true);
expect(event.watermark_downloads).toBe(true);
expect(event.overlay_protection).toBe(false);
});
});
describe('per-category download blocking (#640) on SQLite', () => {
test('a category with allow_downloads = 0 is reported as blocked', async () => {
const cat = await db('photo_categories').insert({
name: 'Blocked', slug: 'blocked', event_id: eventId, is_global: 0, allow_downloads: 0,
}).returning('id');
const categoryId = cat[0]?.id ?? cat[0];
await db('photos').where('id', photoId).update({ category_id: categoryId });
await setEventFlags({ allow_downloads: 1 });
const res = await request(app).get(`/api/gallery/${SLUG}/photos`);
expect(res.status).toBe(200);
const category = res.body.categories.find((c) => c.id === categoryId);
expect(category.allow_downloads).toBe(false);
const photo = res.body.photos.find((p) => p.id === photoId);
expect(photo.category_allow_downloads).toBe(false);
// …and the per-category guard on the single-photo route fires.
const dl = await request(app).get(`/api/gallery/${SLUG}/download/${photoId}`);
expect(dl.status).toBe(403);
});
});
});
@@ -0,0 +1,240 @@
/**
* Hidden/client-only photo access control across the bulk + secure photo
* routes (GHSA cluster: fpwq / ghf8 / 3jvw / 9cc4 / 2hqg / jc22).
*
* A photo with visibility='hidden' is client-only. The main photo-list and
* single-photo download/view routes enforced this, but the bulk-download,
* protected-image, and secure-image routes shipped without the check —
* letting an ordinary guest reach hidden photos. These tests pin that
* guests are refused and PIN-clients (accessLevel='client') still succeed.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-hidden-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'hidden-photo-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-hidden-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'hidden-photo-test-event';
describe('hidden-photo access control (GHSA cluster)', () => {
let db;
let cleanup;
let app;
let eventId;
let visibleId;
let hiddenId;
const guestToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const clientToken = () => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery', accessLevel: 'client' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Hidden Photo Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'hidden-photo-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0, allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const photoDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(photoDir, { recursive: true });
// A real 1x1 PNG so the protected /view route's Sharp processing path
// succeeds (fake bytes 500 on metadata()). Content, not extension,
// drives Sharp's format detection.
const PNG_1x1 = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMCAQGV2rY9AAAAAElFTkSuQmCC',
'base64'
);
const mkPhoto = async (filename, visibility) => {
fs.writeFileSync(path.join(photoDir, filename), PNG_1x1);
const p = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
visibility,
uploaded_at: new Date().toISOString(),
}).returning('id');
return p[0]?.id ?? p[0];
};
visibleId = await mkPhoto('visible.jpg', 'visible');
hiddenId = await mkPhoto('hidden.jpg', 'hidden');
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/images', require('../../src/routes/protectedImages'));
app.use('/api/secure-images', require('../../src/routes/secureImages'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
describe('download-selected (GHSA-ghf8, medium)', () => {
it('omits a hidden photo for a guest even when its id is requested', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${guestToken()}`)
.send({ photo_ids: [visibleId, hiddenId] });
// The visible photo still zips; the hidden one is filtered out. If
// only the hidden id were requested, the filter empties the set → 404.
expect(res.status).toBe(200);
const solo = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${guestToken()}`)
.send({ photo_ids: [hiddenId] });
expect(solo.status).toBe(404);
});
it('includes the hidden photo for a client', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${clientToken()}`)
.send({ photo_ids: [hiddenId] });
expect(res.status).toBe(200);
});
});
describe('download-all (GHSA-fpwq, medium)', () => {
it('streams for a guest without erroring (hidden photos filtered)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download-all`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(200);
});
});
describe('protected-image view (GHSA-9cc4)', () => {
it('403s a hidden photo for a guest', async () => {
const res = await request(app)
.get(`/api/images/${SLUG}/photo/${hiddenId}/view`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(403);
});
it('serves a visible photo for a guest', async () => {
const res = await request(app)
.get(`/api/images/${SLUG}/photo/${visibleId}/view`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(200);
});
it('serves a hidden photo for a client', async () => {
const res = await request(app)
.get(`/api/images/${SLUG}/photo/${hiddenId}/view`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(res.status).toBe(200);
});
});
describe('signed-URL mint (GHSA-3jvw)', () => {
it('403s minting a signed URL for a hidden photo as a guest', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-url`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(403);
});
it('mints for a client', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-url`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(res.status).toBe(200);
expect(res.body.url).toContain('/signed/');
});
});
describe('legacy secure-token mint (protectedImages generate-secure-token)', () => {
it('403s a hidden photo for a guest', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-secure-token`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(res.status).toBe(403);
});
it('mints for a client', async () => {
const res = await request(app)
.post(`/api/images/${SLUG}/photo/${hiddenId}/generate-secure-token`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
});
});
describe('secure-token mint (GHSA-2hqg)', () => {
it('403s minting a secure token for a hidden photo as a guest', async () => {
const res = await request(app)
.post(`/api/secure-images/${SLUG}/generate-token`)
.set('Authorization', `Bearer ${guestToken()}`)
.send({ photoId: hiddenId });
expect(res.status).toBe(403);
});
it('mints for a client', async () => {
const res = await request(app)
.post(`/api/secure-images/${SLUG}/generate-token`)
.set('Authorization', `Bearer ${clientToken()}`)
.send({ photoId: hiddenId });
expect(res.status).toBe(200);
expect(res.body.token).toBeDefined();
});
});
// A capability minted while a photo is visible must stop serving once the
// photo is hidden — unless minted by a client (clientBypass in the token).
describe('signed-URL TOCTOU (hidden AFTER minting)', () => {
afterEach(async () => {
await db('photos').where({ id: visibleId }).update({ visibility: 'visible' });
});
it("a guest's pre-minted signed URL stops serving once the photo is hidden", async () => {
const mint = await request(app)
.post(`/api/images/${SLUG}/photo/${visibleId}/generate-url`)
.set('Authorization', `Bearer ${guestToken()}`);
expect(mint.status).toBe(200);
const url = mint.body.url;
// Still visible → serves.
expect((await request(app).get(url)).status).toBe(200);
// Hide it → the guest token (no clientBypass) must now be refused.
await db('photos').where({ id: visibleId }).update({ visibility: 'hidden' });
expect((await request(app).get(url)).status).toBe(403);
});
it("a client's pre-minted signed URL keeps serving after the photo is hidden", async () => {
const mint = await request(app)
.post(`/api/images/${SLUG}/photo/${visibleId}/generate-url`)
.set('Authorization', `Bearer ${clientToken()}`);
expect(mint.status).toBe(200);
const url = mint.body.url;
await db('photos').where({ id: visibleId }).update({ visibility: 'hidden' });
expect((await request(app).get(url)).status).toBe(200);
});
});
});
@@ -0,0 +1,123 @@
/**
* Logo diagnostic must not leak the filesystem layout, and must mirror what
* resolveLogoFile actually tries (GHSA-29vm, codex round 2).
*
* Round 1 relativised `resolvedTo` and the candidate paths but still echoed
* `sources[].value` verbatim — and branding_logo_path is stored ABSOLUTE by
* multer, so the layout went out anyway. It also dropped the raw-absolute
* candidate, which the resolver retains (subject to containment), making the
* diagnostic report every candidate as missing for a legitimately contained
* absolute logo while `resolvedTo` named the file.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-logodiag-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'logodiag-test-secret';
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('logo diagnostic disclosure (GHSA-29vm)', () => {
let db; let cleanup; let app; let token;
// bootCrmDb() sets STORAGE_PATH itself, so resolve these AFTER it runs.
let STORAGE; let logoDir; let logoPath;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// A legitimately contained absolute logo in a NON-standard storage subdir.
STORAGE = process.env.STORAGE_PATH;
logoDir = path.join(STORAGE, 'custom');
logoPath = path.join(logoDir, 'logo.png');
fs.mkdirSync(logoDir, { recursive: true });
fs.writeFileSync(logoPath, 'png');
const setting = { setting_key: 'branding_logo_path', setting_value: JSON.stringify(logoPath), setting_type: 'branding' };
const existing = await db('app_settings').where({ setting_key: 'branding_logo_path' }).first();
if (existing) await db('app_settings').where({ setting_key: 'branding_logo_path' }).update(setting);
else await db('app_settings').insert(setting);
const role = await db('roles').where({ name: 'super_admin' }).first();
const r = await db('admin_users').insert({
username: 'diag-admin', email: 'diag@example.com',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id, is_active: 1,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
token = jwt.sign(
{ id, username: 'diag-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET, { expiresIn: '1h', issuer: 'picpeak-auth' },
);
app = express();
app.use(express.json());
app.use('/api/admin/business-profile', require('../../src/routes/adminBusinessProfile'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('does not leak absolute paths, cwd or storage root anywhere in the payload', async () => {
const res = await request(app)
.get('/api/admin/business-profile/logo-diagnostic')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
const body = JSON.stringify(res.body);
expect(body).not.toContain(STORAGE);
expect(body).not.toContain(process.cwd());
expect(res.body.storageRoot).toBeUndefined();
expect(res.body.cwd).toBeUndefined();
});
it('still finds a contained absolute logo outside the standard subdirs', async () => {
const res = await request(app)
.get('/api/admin/business-profile/logo-diagnostic')
.set('Authorization', `Bearer ${token}`);
const source = res.body.sources.find((s) => s.label === 'app_settings.branding_logo_path');
expect(source).toBeTruthy();
// The resolver keeps the contained absolute candidate, so the diagnostic
// must show it existing rather than reporting everything missing.
expect(source.candidates.some((c) => c.exists)).toBe(true);
expect(res.body.resolvedTo).toMatch(/^<STORAGE>\//);
});
it('shows the <STORAGE>/<value> candidate for a ROOT-RELATIVE logo URL (round 3)', async () => {
// `/custom/logo.png` is a URL, not a disk path, but path.isAbsolute() says
// true for both. Gating the stripped joins on isAbsolute() therefore hid
// `<STORAGE>/custom/logo.png` — a candidate resolveLogoFile does try and
// can resolve — so the diagnostic claimed nothing existed for a logo that
// renders fine, and collapsed the configured value to its basename.
await db('app_settings').where({ setting_key: 'branding_logo_path' })
.update({ setting_value: JSON.stringify('/custom/logo.png') });
const res = await request(app)
.get('/api/admin/business-profile/logo-diagnostic')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
const source = res.body.sources.find((s) => s.label === 'app_settings.branding_logo_path');
expect(source.candidates.some((c) => c.path === '<STORAGE>/custom/logo.png' && c.exists)).toBe(true);
// …and the disclosure guarantee still holds for this shape.
const body = JSON.stringify(res.body);
expect(body).not.toContain(STORAGE);
expect(body).not.toContain(process.cwd());
await db('app_settings').where({ setting_key: 'branding_logo_path' })
.update({ setting_value: JSON.stringify(logoPath) });
});
});
@@ -0,0 +1,312 @@
/**
* Per-photo engagement counters (#895).
*
* Pins the contract that the admin EVENT > IMAGES table depends on:
* - photos.view_count increments when the full-size photo is served
* (it existed in the schema + admin UI but had NO writer at all)
* - the slideshow kiosk never increments views (migration 138 design)
* - single-photo downloads increment download_count (regression pin)
* - zip downloads (download-all, download-selected) increment
* download_count for the contained photos — previously they didn't,
* so zip-heavy galleries showed 0 per-photo downloads forever
* - the admin event-detail total_downloads counts singles AND zips
* (it counted action='download' only, disagreeing with the dashboard)
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-engagement-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'engagement-test-secret';
// Real files on disk so /photo and the zip routes actually stream bytes.
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-engagement-storage-'));
const request = require('supertest');
const express = require('express');
const cookieParser = require('cookie-parser');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const SLUG = 'engagement-test-event';
describe('photo engagement counters (#895)', () => {
let db;
let cleanup;
let app;
let eventId;
let photoIds;
let adminToken;
const galleryToken = (extra = {}) => jwt.sign(
{ eventId, eventSlug: SLUG, type: 'gallery', ...extra },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const getPhoto = async (id) => db('photos').where('id', id).first();
// The counter writes are fire-and-forget on purpose — give the event
// loop a beat before asserting.
const settle = () => new Promise((r) => setTimeout(r, 400));
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const inserted = await db('events').insert({
slug: SLUG,
event_type: 'wedding',
event_name: 'Engagement Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${SLUG}/share`,
share_token: 'engagement-test-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
eventId = inserted[0]?.id ?? inserted[0];
const photoDir = path.join(process.env.STORAGE_PATH, 'events/active', SLUG);
fs.mkdirSync(photoDir, { recursive: true });
photoIds = [];
for (let i = 0; i < 3; i++) {
const filename = `photo-${i}.jpg`;
fs.writeFileSync(path.join(photoDir, filename), Buffer.from(`fake-jpeg-bytes-${i}`));
const p = await db('photos').insert({
event_id: eventId,
filename,
path: `${SLUG}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
photoIds.push(p[0]?.id ?? p[0]);
}
const superRole = await db('roles').where({ name: 'super_admin' }).first();
const [rootId] = await db('admin_users').insert({
username: 'engagement-admin',
email: 'engagement-admin@example.com',
password_hash: await bcrypt.hash('EngagementAdmin123', 4),
role_id: superRole.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id').then((r) => [r[0]?.id || r[0]]);
adminToken = jwt.sign(
{ id: rootId, username: 'engagement-admin', type: 'admin', role: 'super_admin', loginTime: Date.now() },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
app = express();
app.use(express.json());
app.use(cookieParser());
app.use('/api/gallery', require('../../src/routes/gallery'));
app.use('/api/admin/events', require('../../src/routes/adminEvents'));
app.use('/api/admin/photos', require('../../src/routes/adminPhotos'));
}, 120000);
afterAll(async () => {
if (cleanup) await cleanup();
});
beforeEach(async () => {
await db('photos').where('event_id', eventId).update({ view_count: 0, download_count: 0 });
await db('access_logs').where('event_id', eventId).del();
});
describe('view_count via the view beacon (#895 — previously never written)', () => {
const beacon = (photoId, token = galleryToken()) => request(app)
.post(`/api/gallery/${SLUG}/photo/${photoId}/view`)
.set('Authorization', `Bearer ${token}`);
it('increments exactly the beaconed photo', async () => {
expect((await beacon(photoIds[0])).status).toBe(204);
expect((await getPhoto(photoIds[0])).view_count).toBe(1);
expect((await beacon(photoIds[0])).status).toBe(204);
expect((await getPhoto(photoIds[0])).view_count).toBe(2);
// Other photos untouched
expect((await getPhoto(photoIds[1])).view_count).toBe(0);
});
it('serving the image bytes does NOT count (preloads must not inflate)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/photo/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
await settle();
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
});
it('rejects the slideshow kiosk (migration 138 design)', async () => {
const res = await beacon(photoIds[0], galleryToken({ accessLevel: 'slideshow' }));
expect(res.status).toBeGreaterThanOrEqual(400);
expect((await getPhoto(photoIds[0])).view_count).toBe(0);
});
it("404s a photo that isn't in the event", async () => {
const res = await beacon(999999);
expect(res.status).toBe(404);
});
});
describe('download_count', () => {
it('single-photo download increments (regression pin)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
await settle();
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
expect((await getPhoto(photoIds[1])).download_count).toBe(0);
});
it('download-selected increments exactly the selected photos (#895)', async () => {
const res = await request(app)
.post(`/api/gallery/${SLUG}/download-selected`)
.set('Authorization', `Bearer ${galleryToken()}`)
.send({ photo_ids: [photoIds[0], photoIds[1]] });
expect(res.status).toBe(200);
await settle();
expect((await getPhoto(photoIds[0])).download_count).toBe(1);
expect((await getPhoto(photoIds[1])).download_count).toBe(1);
expect((await getPhoto(photoIds[2])).download_count).toBe(0);
});
it('download-all increments every downloadable photo (#895)', async () => {
const res = await request(app)
.get(`/api/gallery/${SLUG}/download-all`)
.set('Authorization', `Bearer ${galleryToken()}`);
expect(res.status).toBe(200);
await settle();
for (const id of photoIds) {
expect((await getPhoto(id)).download_count).toBe(1);
}
});
it('skipped archive entries do not count (missing source file)', async () => {
// Own event so the on-the-fly archiver path is guaranteed — the
// main event may have a cached zip from the previous test's
// background generation, and racing its build/invalidate hangs.
// The route also fires a background pre-zip build after streaming;
// against this event's intentionally missing file it crashes with
// an async ENOENT that jest attributes to whatever test is running
// by then — neutralize it, it's not under test here.
const downloadZipService = require('../../src/services/downloadZipService');
const generateZipSpy = jest.spyOn(downloadZipService, 'generateZip')
.mockResolvedValue({ success: false, error: 'disabled in test' });
const slug2 = `${SLUG}-skip`;
const ev = await db('events').insert({
slug: slug2,
event_type: 'wedding',
event_name: 'Engagement Skip Test',
event_date: '2026-08-01',
host_email: 'host@example.com',
admin_email: 'admin@example.com',
password_hash: 'x',
share_link: `/gallery/${slug2}/share`,
share_token: 'engagement-skip-share',
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
allow_downloads: 1,
created_at: new Date().toISOString(),
}).returning('id');
const eventId2 = ev[0]?.id ?? ev[0];
const dir2 = path.join(process.env.STORAGE_PATH, 'events/active', slug2);
fs.mkdirSync(dir2, { recursive: true });
const ids2 = [];
for (let i = 0; i < 2; i++) {
// Only photo 0 gets a real file — photo 1's source is missing.
if (i === 0) fs.writeFileSync(path.join(dir2, `photo-${i}.jpg`), Buffer.from('skip-test-bytes'));
const p = await db('photos').insert({
event_id: eventId2,
filename: `photo-${i}.jpg`,
path: `${slug2}/photo-${i}.jpg`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
ids2.push(p[0]?.id ?? p[0]);
}
const token2 = jwt.sign(
{ eventId: eventId2, eventSlug: slug2, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
const res = await request(app)
.get(`/api/gallery/${slug2}/download-all`)
.set('Authorization', `Bearer ${token2}`);
expect(res.status).toBe(200);
await settle();
expect((await db('photos').where('id', ids2[0]).first()).download_count).toBe(1);
// photo-1's source was missing → skipped from the zip → not counted
expect((await db('photos').where('id', ids2[1]).first()).download_count).toBe(0);
generateZipSpy.mockRestore();
});
});
describe('admin photos list exposes the counters (#895 follow-up)', () => {
it('returns view_count and download_count so the Engagement column can render them', async () => {
// The list mapper builds an explicit object — before this fix it
// omitted both fields, so the admin table showed 0 forever even
// though the DB counted correctly.
await request(app)
.post(`/api/gallery/${SLUG}/photo/${photoIds[0]}/view`)
.set('Authorization', `Bearer ${galleryToken()}`);
await request(app)
.get(`/api/gallery/${SLUG}/download/${photoIds[0]}`)
.set('Authorization', `Bearer ${galleryToken()}`);
await settle();
const res = await request(app)
.get(`/api/admin/photos/${eventId}/photos`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
const row = res.body.photos.find((p) => p.id === photoIds[0]);
expect(row.view_count).toBe(1);
expect(row.download_count).toBe(1);
const untouched = res.body.photos.find((p) => p.id === photoIds[1]);
expect(untouched.view_count).toBe(0);
expect(untouched.download_count).toBe(0);
});
});
describe('admin event-detail total_downloads (#895 — one definition everywhere)', () => {
it('counts singles and every zip variant, one row each', async () => {
const row = (action) => ({
event_id: eventId,
ip_address: '127.0.0.1',
user_agent: 'jest',
action,
});
await db('access_logs').insert([
row('download'),
row('download_all'),
row('download_all_presigned'),
row('download_selected'),
row('view'), // not a download
]);
const res = await request(app)
.get(`/api/admin/events/${eventId}`)
.set('Authorization', `Bearer ${adminToken}`);
expect(res.status).toBe(200);
expect(res.body.total_downloads).toBe(4);
});
});
});
@@ -0,0 +1,181 @@
/**
* Project ownership — GHSA-wrg5 (project routes) and GHSA-93x4 (project email
* endpoints).
*
* Project routes authorized on generic events.view / events.edit with NO
* ownership check, so an editor could enumerate, read, update and aggregate
* projects belonging to other admins' events. The email endpoints keyed on an
* email_queue id alone, so any id could be previewed/resent/cancelled.
*
* `projects` had no owner column. It was added in migration 167 (backfilled
* from linked events) rather than relying only on the transitive
* events.project_id -> events.created_by path, because a brand-new EMPTY
* project has no linked event to infer an owner from — which is exactly where
* the create -> attach flow begins.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-projown-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'projown-test-secret';
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('project ownership (GHSA-wrg5 / GHSA-93x4)', () => {
let db; let cleanup; let app;
let editorToken; let superToken; let editorId; let superId;
let ownProjectId; let foreignProjectId; let foreignEventId; let foreignEmailId;
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const id = r[0]?.id ?? r[0];
return {
id,
token: jwt.sign(
{ id, username, type: 'admin', role: roleName, loginTime: Date.now() },
process.env.JWT_SECRET, { expiresIn: '1h', issuer: 'picpeak-auth' },
),
};
};
const mkProject = async (name, createdBy) => {
const r = await db('projects').insert({
name, status: 'active', created_by: createdBy,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
await db('feature_flags').insert({ key: 'projects', value: 1 })
.onConflict('key').merge({ value: 1 });
const editor = await mkAdmin('proj-editor', 'editor');
const sup = await mkAdmin('proj-super', 'super_admin');
editorToken = editor.token; editorId = editor.id;
superToken = sup.token; superId = sup.id;
ownProjectId = await mkProject('own-project', editorId);
foreignProjectId = await mkProject('foreign-project', superId);
// A foreign event linked to the foreign project, plus a queued email on it.
const ev = await db('events').insert({
slug: 'foreign-ev',
event_type: 'wedding',
event_name: 'Foreign Event',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: 'ftok', share_link: '/gallery/foreign-ev/ftok',
created_by: superId,
project_id: foreignProjectId,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
foreignEventId = ev[0]?.id ?? ev[0];
const em = await db('email_queue').insert({
event_id: foreignEventId,
recipient_email: 'client@example.com',
email_type: 'gallery_created',
status: 'sent',
created_at: new Date().toISOString(),
}).returning('id');
foreignEmailId = em[0]?.id ?? em[0];
app = express();
app.use(express.json());
app.use('/api/admin/projects', require('../../src/routes/adminProjects'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('lists only the editor\'s own projects', async () => {
const res = await request(app)
.get('/api/admin/projects')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const names = (res.body.projects || res.body.data?.projects || []).map((p) => p.name);
expect(names).toContain('own-project');
expect(names).not.toContain('foreign-project');
});
it('refuses to read a foreign project', async () => {
const res = await request(app)
.get(`/api/admin/projects/${foreignProjectId}`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(res.status);
});
it('refuses to update or aggregate a foreign project', async () => {
const update = await request(app)
.put(`/api/admin/projects/${foreignProjectId}`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ name: 'hijacked' });
expect([403, 404]).toContain(update.status);
const overview = await request(app)
.get(`/api/admin/projects/${foreignProjectId}/overview`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(overview.status);
// And the name must not have changed.
const row = await db('projects').where({ id: foreignProjectId }).first();
expect(row.name).toBe('foreign-project');
});
it('refuses to attach a FOREIGN event to an owned project', async () => {
const res = await request(app)
.post(`/api/admin/projects/${ownProjectId}/events`)
.set('Authorization', `Bearer ${editorToken}`)
.send({ eventId: foreignEventId });
expect([403, 404]).toContain(res.status);
const ev = await db('events').where({ id: foreignEventId }).first();
expect(ev.project_id).toBe(foreignProjectId); // still attached to its own
});
it('refuses to preview or act on a foreign queued email (GHSA-93x4)', async () => {
const preview = await request(app)
.get(`/api/admin/projects/email/${foreignEmailId}/preview`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(preview.status);
const cancel = await request(app)
.post(`/api/admin/projects/email/${foreignEmailId}/cancel`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(cancel.status);
});
it('leaves super_admin unrestricted', async () => {
const res = await request(app)
.get(`/api/admin/projects/${foreignProjectId}`)
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).toBe(200);
});
});
@@ -0,0 +1,115 @@
/**
* Project ownership edge cases (GHSA-wrg5, codex round 2).
*
* The first predicate union'd "any linked event I can see" with the stored
* owner, which opened two holes:
* - a project owned by B containing ONE legacy ownerless event became
* readable by everyone (and /overview aggregates B's other events,
* invoices and emails);
* - migration 167 deliberately leaves multi-owner projects NULL, and a NULL
* owner was treated as "everyone's".
* The stored owner is now authoritative, and a NULL owner only derives access
* when EVERY linked event is accessible.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-projedge-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'projedge-test-secret';
const bcrypt3 = require('bcrypt');
const { bootCrmDb: boot3, seedMinimal: seed3 } = require('../integration/helpers/crmDb');
describe('project ownership edge cases (GHSA-wrg5, round 2)', () => {
let db3; let cleanup3; let ownership; let editorA; let editorB;
const mkAdmin3 = async (username, roleName) => {
const role = await db3('roles').where({ name: roleName }).first();
const r = await db3('admin_users').insert({
username, email: `${username}@example.com`,
password_hash: await bcrypt3.hash('Passw0rd!', 4),
role_id: role.id, is_active: 1,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkProject3 = async (name, createdBy) => {
const r = await db3('projects').insert({
name, status: 'active', created_by: createdBy,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkEvent3 = async (slug, createdBy, projectId) => {
const r = await db3('events').insert({
slug, event_type: 'wedding', event_name: slug, event_date: '2026-08-01',
host_email: 'h@e.com', admin_email: 'a@e.com', password_hash: 'x',
share_token: `t-${slug}`, share_link: `/g/${slug}/t-${slug}`,
created_by: createdBy, project_id: projectId,
expires_at: new Date(Date.now() + 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db: db3, cleanup: cleanup3 } = await boot3());
await seed3(db3);
ownership = require('../../src/middleware/ownership');
editorA = await mkAdmin3('edge-a', 'editor');
editorB = await mkAdmin3('edge-b', 'editor');
}, 120000);
afterAll(async () => { if (cleanup3) await cleanup3(); });
it('one ownerless event in B\'s project does not expose it to A', async () => {
const pid = await mkProject3('b-project', editorB);
await mkEvent3('b-owned-ev', editorB, pid);
await mkEvent3('legacy-ev', null, pid); // ownerless legacy event
const idsA = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
expect(idsA).not.toContain(Number(pid));
const idsB = await ownership.ownedProjectIds({ id: editorB, roleName: 'editor' });
expect(idsB).toContain(Number(pid));
});
it('a mixed-owner project left NULL by migration 167 is not global', async () => {
const pid = await mkProject3('ambiguous', null);
await mkEvent3('mix-a-ev', editorA, pid);
await mkEvent3('mix-b-ev', editorB, pid);
for (const who of [editorA, editorB]) {
const ids = await ownership.ownedProjectIds({ id: who, roleName: 'editor' });
expect(ids).not.toContain(Number(pid));
}
});
it('a NULL-owner project whose events are all mine IS mine', async () => {
const pid = await mkProject3('legacy-mine', null);
await mkEvent3('mine-ev', editorA, pid);
const ids = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
expect(ids).toContain(Number(pid));
});
it('a project whose creator was deleted falls back to its events', async () => {
const ghost = await mkAdmin3('ghost-admin', 'editor');
const pid = await mkProject3('orphaned', ghost);
await mkEvent3('orphan-ev', editorA, pid);
await db3('admin_users').where({ id: ghost }).del();
const ids = await ownership.ownedProjectIds({ id: editorA, roleName: 'editor' });
expect(ids).toContain(Number(pid));
});
it('super_admin stays unrestricted', async () => {
expect(await ownership.ownedProjectIds({ id: 1, roleName: 'super_admin' })).toBeNull();
});
});
@@ -0,0 +1,86 @@
/**
* Restore path containment must not break the normal restore wizard
* (GHSA-fw4c, codex round 2).
*
* `source` is usually a SOURCE TYPE, not a path: RestoreWizard posts
* 'local' | 's3' | 'upload', and restoreService.restore() branches on those
* literals before deriving a directory. The first version of the containment
* check treated `source` as a path, so path.resolve('local') landed outside
* the configured backup roots and BOTH /validate and /start returned 400 —
* blocking every normal restore.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-restorepath-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'restorepath-test-secret';
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('restore path allowlist (GHSA-fw4c)', () => {
let db; let cleanup; let checkRestorePathsAllowed;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// Configure a backup root so the allowlist is actually active.
for (const [key, value] of [['backup_destination_path', '/backup']]) {
const existing = await db('app_settings').where({ setting_key: key }).first();
if (existing) {
await db('app_settings').where({ setting_key: key }).update({ setting_value: JSON.stringify(value) });
} else {
await db('app_settings').insert({
setting_key: key, setting_value: JSON.stringify(value), setting_type: 'backup',
});
}
}
({ checkRestorePathsAllowed } = require('../../src/routes/adminRestore')._internal);
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('allows the wizard\'s source TYPE tokens', async () => {
for (const source of ['local', 's3', 'upload']) {
const err = await checkRestorePathsAllowed({
source, manifestPath: '/backup/manifests/backup-manifest-1.json',
});
expect(err).toBeNull();
}
});
it('allows an s3:// source URL', async () => {
const err = await checkRestorePathsAllowed({
source: 's3://bucket/key/backup.tar.gz',
manifestPath: '/backup/manifests/backup-manifest-1.json',
});
expect(err).toBeNull();
});
it('still rejects a manifestPath outside the configured roots', async () => {
const err = await checkRestorePathsAllowed({
source: 'local', manifestPath: '/etc/passwd',
});
expect(err).toMatch(/inside a configured backup location/i);
});
it('still rejects a traversal manifestPath', async () => {
const err = await checkRestorePathsAllowed({
source: 'local', manifestPath: '/backup/../etc/shadow',
});
expect(err).toBeTruthy();
});
it('accepts a real path source inside the roots', async () => {
const err = await checkRestorePathsAllowed({
source: '/backup/run-1', manifestPath: '/backup/run-1/manifest.json',
});
expect(err).toBeNull();
});
});
@@ -0,0 +1,136 @@
/**
* Secure-image view route token binding (GHSA-g94x-8vv8-3c9f).
*
* The view route GET /api/secure-images/:slug/secure/:photoId/:token serves
* via <img src> with the token in the URL, so it can't carry a gallery-token
* header like the download sibling. Before the fix it validated only the
* token signature and took the gallery/photo from the URL — so a token minted
* on any PUBLIC gallery read every other gallery's photos with no password.
*
* Pins that the route now enforces the scope inside the token:
* - the URL photoId must equal the token's minted photoId
* - the gallery embedded in the token's sessionId must equal the URL gallery
* A token minted on gallery A cannot read gallery B under either check; a
* token used on its own gallery+photo passes the binding.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-secimg-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'secimg-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-secimg-storage-'));
// Stub the anti-bot/rate-limit middleware so the fingerprint is deterministic
// — the token below is minted with the same fingerprint, so verifySecureToken
// passes and the binding logic under test is what decides the outcome.
jest.mock('../../src/middleware/secureImageMiddleware', () => ({
secureImageAccess: (req, _res, next) => {
req.clientInfo = { fingerprint: 'test-fp', ip: '127.0.0.1', userAgent: 'jest' };
next();
},
getSecurityStatus: (_req, res) => res.json({ ok: true }),
}));
const request = require('supertest');
const express = require('express');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const secureImageService = require('../../src/services/secureImageService');
describe('secure-image view route token binding (GHSA-g94x)', () => {
let db;
let cleanup;
let app;
let galleryA; let galleryB;
let photoA; let photoB;
const mkEvent = async (slug, requirePassword) => {
const r = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
require_password: requirePassword ? 1 : 0,
share_link: `/gallery/${slug}/share`,
share_token: `${slug}-share`,
expires_at: new Date(Date.now() + 7 * 24 * 3600 * 1000).toISOString(),
is_active: 1,
is_archived: 0,
is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkPhoto = async (eventId, slug, filename) => {
const dir = path.join(process.env.STORAGE_PATH, 'events/active', slug);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, filename), Buffer.from('img'));
const r = await db('photos').insert({
event_id: eventId,
filename,
path: `${slug}/${filename}`,
type: 'individual',
uploaded_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
// Mint a token exactly as the mint route does — bound to (photoId, gallery
// sessionId, fingerprint) — bypassing the anti-bot HTTP path.
const mint = (photoId, eventId) => secureImageService.generateSecureToken(
photoId,
`gallery_public_${eventId}_${Date.now()}`,
{ clientFingerprint: 'test-fp', maxUses: 100, expiresIn: 3600 },
);
const view = (slug, photoId, token) => request(app)
.get(`/api/secure-images/${slug}/secure/${photoId}/${token}`);
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
galleryA = await mkEvent('secimg-public-a', false); // public — token source
galleryB = await mkEvent('secimg-private-b', true); // password-protected — victim
photoA = await mkPhoto(galleryA, 'secimg-public-a', 'a.jpg');
photoB = await mkPhoto(galleryB, 'secimg-private-b', 'b.jpg');
app = express();
app.use(express.json());
app.use('/api/secure-images', require('../../src/routes/secureImages'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('rejects a gallery-A token used against gallery B (cross-photo)', async () => {
const token = mint(photoA, galleryA);
const res = await view('secimg-private-b', photoB, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this photo/i);
});
it('rejects a gallery-A token replayed on gallery B with A\'s photoId', async () => {
const token = mint(photoA, galleryA);
// URL photoId matches the token, so the photo check passes — the gallery
// check (sessionId gallery A != URL gallery B) must catch it.
const res = await view('secimg-private-b', photoA, token);
expect(res.status).toBe(403);
expect(res.body.error).toMatch(/not valid for this gallery/i);
});
it('lets a token read its own gallery + photo (binding passes)', async () => {
const token = mint(photoA, galleryA);
const res = await view('secimg-public-a', photoA, token);
// Binding passes; serving may 200/404/500 depending on the pipeline, but
// it must NOT be rejected as a token mismatch.
expect(res.status).not.toBe(403);
});
});
@@ -0,0 +1,155 @@
/**
* v1 API tokens must respect event ownership (GHSA-9697).
*
* migration 081 documents the intent — "the token's effective permissions are
* the intersection of the user's role permissions and the token's own scope
* flags" — but it was never implemented:
*
* - apiTokenAuth selected only id/username/email/role_id, so
* req.admin.roleName was undefined and every ownership helper (which all
* key on roleName) could not distinguish a super_admin from a viewer.
* - No v1 route applied requirePermission or a created_by predicate, so any
* valid token listed every event and — worst — GET /events/:id/share-link
* returned ANY event's share_token, which is the gallery access credential.
*
* Scenario pinned here: a token owned by a restricted (non-super_admin) admin
* must see only its owner's events, and must not obtain a foreign share_token.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1own-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'v1own-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1own-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { generateApiToken } = require('../../src/middleware/apiTokenAuth');
describe('v1 event ownership (GHSA-9697)', () => {
let db; let cleanup; let app;
let editorToken; let superToken;
let ownEventId; let foreignEventId;
const FOREIGN_SHARE_TOKEN = 'f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0f0';
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username,
email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkToken = async (adminId, scopes = 'admin') => {
const { plaintext, hashed } = generateApiToken();
await db('api_tokens').insert({
name: `tok-${adminId}`,
hashed_token: hashed,
scopes,
created_by: adminId,
created_at: new Date().toISOString(),
});
return plaintext;
};
const mkEvent = async (slug, createdBy, shareToken) => {
const r = await db('events').insert({
slug,
event_type: 'wedding',
event_name: slug,
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: shareToken,
share_link: `/gallery/${slug}/${shareToken}`,
created_by: createdBy,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const editorId = await mkAdmin('restricted-editor', 'editor');
const superId = await mkAdmin('root-admin', 'super_admin');
editorToken = await mkToken(editorId);
superToken = await mkToken(superId);
ownEventId = await mkEvent('own-event', editorId, 'a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1');
foreignEventId = await mkEvent('foreign-event', superId, FOREIGN_SHARE_TOKEN);
app = express();
app.use(express.json());
app.use('/api/v1', require('../../src/routes/v1/events'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('lists only the token owner\'s events', async () => {
const res = await request(app)
.get('/api/v1/events')
.set('Authorization', `Bearer ${editorToken}`);
expect(res.status).toBe(200);
const slugs = res.body.events.map((e) => e.slug);
expect(slugs).toContain('own-event');
expect(slugs).not.toContain('foreign-event');
});
it('refuses to read a foreign event', async () => {
const res = await request(app)
.get(`/api/v1/events/${foreignEventId}`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(res.status);
});
it('does NOT hand out a foreign event\'s share_token', async () => {
const res = await request(app)
.get(`/api/v1/events/${foreignEventId}/share-link`)
.set('Authorization', `Bearer ${editorToken}`);
expect([403, 404]).toContain(res.status);
expect(JSON.stringify(res.body)).not.toContain(FOREIGN_SHARE_TOKEN);
});
it('still allows the owner to read their own event and share link', async () => {
const detail = await request(app)
.get(`/api/v1/events/${ownEventId}`)
.set('Authorization', `Bearer ${editorToken}`);
expect(detail.status).toBe(200);
const share = await request(app)
.get(`/api/v1/events/${ownEventId}/share-link`)
.set('Authorization', `Bearer ${editorToken}`);
expect(share.status).toBe(200);
expect(share.body.share_token).toBe('a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a1');
});
it('leaves super_admin tokens unrestricted', async () => {
const res = await request(app)
.get(`/api/v1/events/${foreignEventId}/share-link`)
.set('Authorization', `Bearer ${superToken}`);
expect(res.status).toBe(200);
expect(res.body.share_token).toBe(FOREIGN_SHARE_TOKEN);
});
});
@@ -0,0 +1,108 @@
/**
* v1 token scopes must intersect the owner's CURRENT role permissions
* (GHSA-9697, codex round 2).
*
* Migration 081 documents effective permissions as the intersection of the
* owner's role permissions and the token's scope flags. requireApiScope only
* ever checked the scope half, so a token minted while its owner was
* super_admin kept full write access after the owner was demoted to viewer —
* userManagementService never touches api_tokens, so the token outlives the
* demotion. Ownership scoping alone does not close this: the demoted owner
* still *owns* their events.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1perm-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'v1perm-test-secret';
process.env.STORAGE_PATH = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-v1perm-storage-'));
const request = require('supertest');
const express = require('express');
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
const { generateApiToken } = require('../../src/middleware/apiTokenAuth');
describe('v1 token scopes intersect role permissions (GHSA-9697)', () => {
let db; let cleanup; let app; let viewerToken; let viewerEventId;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
const role = await db('roles').where({ name: 'viewer' }).first();
const r = await db('admin_users').insert({
username: 'demoted-owner',
email: 'demoted@example.com',
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id,
is_active: 1,
created_at: new Date(),
updated_at: new Date(),
}).returning('id');
const ownerId = r[0]?.id ?? r[0];
// A token still carrying the broad 'admin' scope from before demotion.
const { plaintext, hashed } = generateApiToken();
await db('api_tokens').insert({
name: 'stale-token',
hashed_token: hashed,
scopes: 'admin',
created_by: ownerId,
created_at: new Date().toISOString(),
});
viewerToken = plaintext;
const ev = await db('events').insert({
slug: 'viewer-ev',
event_type: 'wedding',
event_name: 'Viewer Event',
event_date: '2026-08-01',
host_email: 'h@example.com',
admin_email: 'a@example.com',
password_hash: 'x',
share_token: 'vtok',
share_link: '/gallery/viewer-ev/vtok',
created_by: ownerId,
expires_at: new Date(Date.now() + 7 * 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
viewerEventId = ev[0]?.id ?? ev[0];
app = express();
app.use(express.json());
app.use('/api/v1', require('../../src/routes/v1/events'));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('denies event creation to a demoted viewer despite an admin-scope token', async () => {
const res = await request(app)
.post('/api/v1/events')
.set('Authorization', `Bearer ${viewerToken}`)
.send({ event_name: 'Nope', event_type: 'wedding' });
expect(res.status).toBe(403);
});
it('denies photo upload to a demoted viewer on their OWN event', async () => {
const res = await request(app)
.post(`/api/v1/events/${viewerEventId}/photos`)
.set('Authorization', `Bearer ${viewerToken}`)
.attach('photo', Buffer.from('x'), 'a.jpg');
expect(res.status).toBe(403);
});
it('still allows the viewer to READ their own event', async () => {
const res = await request(app)
.get(`/api/v1/events/${viewerEventId}`)
.set('Authorization', `Bearer ${viewerToken}`);
expect(res.status).toBe(200);
});
});
@@ -0,0 +1,241 @@
/**
* Backup/restore hardening — GHSA-h652 (unbounded gunzip) and GHSA-hgp8
* (unkeyed manifest checksum).
*
* h652: decompressFile() piped gunzip straight to disk with no expanded-size
* bound, so a small crafted .gz could fill the volume.
*
* hgp8: the manifest checksum is a plain SHA-256 — it proves the manifest was
* not corrupted, not that it is authentic. BACKUP_MANIFEST_KEY upgrades new
* manifests to a keyed HMAC. It is deliberately OPT-IN and verify-if-present:
* the key cannot live in the database (the database is inside the backup), so
* a mandatory HMAC would lock an operator out of the exact disaster-recovery
* case this system exists for.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
const zlib = require('zlib');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-bkharden-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'bkharden-test-secret';
const { restoreService } = require('../../src/services/restoreService');
const backupManifest = require('../../src/services/backupManifest');
describe('decompressFile expanded-size bound (GHSA-h652)', () => {
let dir;
beforeAll(() => { dir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-gz-')); });
afterAll(() => { fs.rmSync(dir, { recursive: true, force: true }); });
afterEach(() => { delete process.env.RESTORE_MAX_DECOMPRESSED_BYTES; });
it('aborts when the decompressed stream exceeds the limit', async () => {
// 5 MB of zeroes compresses to a few KB — the classic shape of the attack.
const gzPath = path.join(dir, 'bomb.gz');
fs.writeFileSync(gzPath, zlib.gzipSync(Buffer.alloc(5 * 1024 * 1024, 0)));
process.env.RESTORE_MAX_DECOMPRESSED_BYTES = String(64 * 1024); // 64 KB
await expect(
restoreService.decompressFile(gzPath, path.join(dir, 'out-bomb'))
).rejects.toThrow(/exceeds limit/i);
});
it('still decompresses a normal file within the limit', async () => {
const payload = Buffer.from('SELECT 1;\n'.repeat(100));
const gzPath = path.join(dir, 'ok.gz');
fs.writeFileSync(gzPath, zlib.gzipSync(payload));
const outPath = path.join(dir, 'out-ok');
await restoreService.decompressFile(gzPath, outPath);
expect(fs.readFileSync(outPath)).toEqual(payload);
});
});
describe('manifest checksum keying (GHSA-hgp8)', () => {
// validateManifest requires all of these sections to be present.
const baseManifest = () => ({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
database: { type: 'sqlite' },
verification: { total_checksum: null, checksum_algorithm: null },
});
afterEach(() => { delete process.env.BACKUP_MANIFEST_KEY; });
it('produces a different digest when a key is set', () => {
const m = baseManifest();
const unkeyed = backupManifest.calculateManifestChecksum(m, { keyed: false });
const keyed = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
expect(keyed).not.toBe(unkeyed);
});
it('validates a legacy unkeyed manifest even when a key IS configured', () => {
// Disaster recovery: manifests written before keying must not become
// un-restorable the moment the operator sets a key.
const m = baseManifest();
m.verification.checksum_algorithm = 'sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
it('accepts a keyed manifest when the matching key is configured', () => {
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
const m = baseManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
it('rejects a keyed manifest whose body was tampered with', () => {
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
const m = baseManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
m.files.manifest[0].path = '../../etc/passwd';
expect(() => backupManifest.validateManifest(m)).toThrow(/checksum verification failed/i);
});
it('does NOT brick restore when a keyed manifest meets a missing key', () => {
// Key lost with the host — the precise moment a restore is needed.
const m = baseManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'secret-key' });
delete process.env.BACKUP_MANIFEST_KEY;
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
});
describe('manifest checksum coverage (canonicalization)', () => {
const fullManifest = () => ({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
database: { type: 'sqlite' },
verification: { total_checksum: null, checksum_algorithm: 'sha256' },
});
afterEach(() => { delete process.env.BACKUP_MANIFEST_KEY; });
it('covers nested file entries (the old replacer dropped them)', () => {
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
// Tampering a file path must now change the digest.
m.files.manifest[0].path = '../../etc/passwd';
expect(() => backupManifest.validateManifest(m)).toThrow(/checksum verification failed/i);
});
it('still accepts a manifest written with the legacy serialization', () => {
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(
m, { keyed: false, legacy: true }
);
expect(() => backupManifest.validateManifest(m)).not.toThrow();
});
});
describe('checksum verification is shared and downgrade-aware (codex round 2)', () => {
const fullManifest = () => ({
manifest: { version: '1.0', id: 'test' },
backup: { type: 'full' },
system: { platform: 'linux' },
application: { version: '1.0.0' },
files: { count: 1, manifest: [{ path: 'a.jpg', size: 1 }] },
database: { type: 'sqlite' },
verification: { total_checksum: null, checksum_algorithm: 'sha256' },
});
afterEach(() => {
delete process.env.BACKUP_MANIFEST_KEY;
delete process.env.BACKUP_MANIFEST_REQUIRE_KEYED;
});
it('accepts a legacy-serialized manifest through the SHARED verifier', () => {
// restoreService recomputed the digest itself with the canonical
// serializer, which rejected every pre-existing backup.
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(
m, { keyed: false, legacy: true },
);
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(true);
expect(res.warnings.join(' ')).toMatch(/legacy checksum serialization/i);
});
it('warns but accepts an unkeyed manifest when a key is configured', () => {
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(true);
expect(res.warnings.join(' ')).toMatch(/authenticity NOT established/i);
});
it('REJECTS the algorithm downgrade once REQUIRE_KEYED is on', () => {
// Attacker rewrites the manifest, strips checksum_algorithm and recomputes
// a plain SHA-256. With the strict flag set that must not verify.
const m = fullManifest();
m.files.manifest[0].path = '../../etc/passwd';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_KEY = 'secret-key';
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(false);
expect(res.error).toMatch(/downgrade/i);
});
it('rejects a keyed manifest with no key when REQUIRE_KEYED is on', () => {
const m = fullManifest();
m.verification.checksum_algorithm = 'hmac-sha256';
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: 'k' });
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
expect(backupManifest.verifyManifestChecksum(m).valid).toBe(false);
});
it('REJECTS a manifest whose checksum was stripped entirely', () => {
// The cheapest bypass of every rule above: delete the field instead of
// forging it. Both the helper's early return and restoreService's
// `if (…total_checksum)` guard used to wave that through.
const m = fullManifest();
delete m.verification.total_checksum;
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(false);
expect(res.error).toMatch(/no checksum/i);
delete m.verification;
expect(backupManifest.verifyManifestChecksum(m).valid).toBe(false);
});
it('REJECTS an unkeyed manifest under REQUIRE_KEYED even with no key configured', () => {
// Strict mode is a claim about the manifests, not about this host — so a
// fresh disaster-recovery box that lost BACKUP_MANIFEST_KEY must not
// silently start accepting plain SHA-256 manifests again.
const m = fullManifest();
m.verification.total_checksum = backupManifest.calculateManifestChecksum(m, { keyed: false });
process.env.BACKUP_MANIFEST_REQUIRE_KEYED = 'true';
delete process.env.BACKUP_MANIFEST_KEY;
const res = backupManifest.verifyManifestChecksum(m);
expect(res.valid).toBe(false);
expect(res.error).toMatch(/downgrade/i);
});
});
@@ -0,0 +1,127 @@
/**
* Inbound-mail resource caps (GHSA-2qf9).
*
* emailIntakeService downloaded, parsed and persisted every message with no
* size, attachment-count or attachment-byte limit. Anyone who can email the
* operator's mailbox reaches this path unauthenticated.
*
* The teeth were in the dedup key: on failure the service wrote an error row
* keyed `err-<uid>-<Date.now()>`, which can never match the envelope-derived
* `messageId` the dedup pass compares against. So the same oversized message
* was re-downloaded every poll interval forever — and an OOM-kill/restart just
* resumed the loop. This pins that an over-limit message is (a) never
* downloaded and (b) recorded under its REAL message id so it dedups.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-intake-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'intake-test-secret';
process.env.EMAIL_INTAKE_MAX_MESSAGE_BYTES = '1000';
const OVERSIZED_UID = 11;
const NORMAL_UID = 12;
const OVERSIZED_MSGID = '<huge@example.com>';
const fetchOneCalls = [];
jest.mock('imapflow', () => ({
ImapFlow: class {
async connect() {}
async logout() {}
async getMailboxLock() { return { release() {} }; }
async search() { return [OVERSIZED_UID, NORMAL_UID]; }
// Envelope pass now also returns `size`.
async *fetch() {
yield { uid: OVERSIZED_UID, size: 50_000, envelope: { messageId: OVERSIZED_MSGID } };
yield { uid: NORMAL_UID, size: 500, envelope: { messageId: '<ok@example.com>' } };
}
async fetchOne(uid) {
fetchOneCalls.push(String(uid));
return { source: Buffer.from('Subject: ok\r\n\r\nbody') };
}
async messageFlagsAdd() { return true; }
},
}));
jest.mock('mailparser', () => ({
simpleParser: async () => ({
messageId: '<ok@example.com>',
subject: 'ok',
date: new Date(),
attachments: [],
text: 'body',
html: null,
}),
}));
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('email intake caps (GHSA-2qf9)', () => {
let db; let cleanup; let intake;
let pollResult;
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
// pollOnce short-circuits unless the feature flag is on AND an IMAP
// account is configured — without both, this suite would pass vacuously.
await db('feature_flags')
.insert({ key: 'incomingMail', value: 1 })
.onConflict('key').merge({ value: 1 });
// getImapConfig() reads email_configs.first() — seedMinimal may already
// have inserted a row, so update that one rather than adding a second
// (the first row would win and report "unconfigured").
const imapFields = {
imap_host: 'imap.example.com',
imap_user: 'intake@example.com',
imap_pass: 'x',
imap_folder: 'INBOX',
};
const existingCfg = await db('email_configs').first();
if (existingCfg) {
await db('email_configs').where({ id: existingCfg.id }).update(imapFields);
} else {
await db('email_configs').insert({
smtp_host: 'smtp.example.com',
smtp_port: 587,
from_email: 'intake@example.com',
...imapFields,
});
}
intake = require('../../src/services/emailIntakeService');
pollResult = await intake.pollOnce().catch((e) => ({ thrown: e.message }));
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it('actually ran the poll (guards against a vacuous suite)', () => {
expect(pollResult).toBeDefined();
expect(pollResult.skipped).toBeUndefined();
});
it('never downloads a message whose envelope size exceeds the cap', () => {
// The oversized uid must never reach fetchOne (the source download) —
// that download is the DoS. The normal one must still be processed.
expect(fetchOneCalls).not.toContain(String(OVERSIZED_UID));
expect(fetchOneCalls).toContain(String(NORMAL_UID));
});
it('records the skip under the REAL message id so it dedups next poll', async () => {
const row = await db('received_emails').where({ message_id: OVERSIZED_MSGID }).first();
expect(row).toBeTruthy();
expect(row.status).toBe('error');
expect(String(row.error)).toMatch(/too large/i);
// The whole point: keyed by messageId, NOT err-<uid>-<timestamp>, which
// could never match the dedup pass and so looped forever.
expect(row.message_id).not.toMatch(/^err-/);
});
});
@@ -0,0 +1,229 @@
/**
* Deal-lineage ownership on project attach (GHSA-wrg5, codex round 3).
*
* requireProjectOwnership vets only the DESTINATION project. Attaching a quote
* cascades through linkDealToProject, which re-points every event the deal
* produced into that project — so an editor could create an empty project of
* their own, attach another admin's quote, and pull that admin's events (and
* the invoices, emails and gallery that roll up with them) into a project they
* own and can read via /:id/overview. An unassigned project offered no
* resistance either: it ADOPTS the deal's customer rather than rejecting it.
*/
const path = require('path');
const fs = require('fs');
const os = require('os');
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(
fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-deallineage-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'deallineage-test-secret';
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('linkDealToProject enforces lineage ownership (GHSA-wrg5, round 3)', () => {
let db; let cleanup; let projectService;
let editorA; let editorB; let superAdmin;
let customerId;
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username, email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id, is_active: 1,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkProject = async (name, createdBy) => {
const r = await db('projects').insert({
name, status: 'active', created_by: createdBy,
created_at: new Date(), updated_at: new Date(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkEvent = async (slug, createdBy) => {
const r = await db('events').insert({
slug, event_type: 'wedding', event_name: slug, event_date: '2026-08-01',
host_email: 'h@e.com', admin_email: 'a@e.com', password_hash: 'x',
share_token: `t-${slug}`, share_link: `/g/${slug}/t-${slug}`,
created_by: createdBy,
expires_at: new Date(Date.now() + 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkQuote = async (dealUuid, convertedEventId) => {
const r = await db('quotes').insert({
quote_number: `Q-${dealUuid}`,
customer_account_id: customerId,
deal_uuid: dealUuid,
converted_event_id: convertedEventId,
status: 'accepted',
currency: 'EUR',
issue_date: '2026-08-01',
total_amount_minor: 1000,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
projectService = require('../../src/services/projectService');
editorA = await mkAdmin('deal-a', 'editor');
editorB = await mkAdmin('deal-b', 'editor');
superAdmin = await mkAdmin('deal-root', 'super_admin');
const c = await db('customer_accounts').first('id');
customerId = c.id;
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
it("refuses to move another admin's event into the caller's project", async () => {
const victimEvent = await mkEvent('victim-gala', editorB);
const quoteId = await mkQuote('deal-foreign', victimEvent);
const attackerProject = await mkProject('attacker-empty', editorA);
await expect(
projectService.assignQuote(attackerProject, quoteId, { id: editorA, roleName: 'editor' }),
).rejects.toMatchObject({ code: 'DEAL_EVENT_FORBIDDEN' });
// Nothing may be half-applied: neither the event nor the quote moved.
const ev = await db('events').where({ id: victimEvent }).first('project_id');
expect(ev.project_id == null).toBe(true);
const q = await db('quotes').where({ id: quoteId }).first('project_id');
expect(q.project_id == null).toBe(true);
});
it("allows the caller's own event through the same path", async () => {
const ownEvent = await mkEvent('own-gala', editorA);
const quoteId = await mkQuote('deal-own', ownEvent);
const project = await mkProject('attacker-own', editorA);
await projectService.assignQuote(project, quoteId, { id: editorA, roleName: 'editor' });
const ev = await db('events').where({ id: ownEvent }).first('project_id');
expect(Number(ev.project_id)).toBe(Number(project));
});
it('leaves super_admin unrestricted', async () => {
const victimEvent = await mkEvent('root-gala', editorB);
const quoteId = await mkQuote('deal-root', victimEvent);
const project = await mkProject('root-project', superAdmin);
await projectService.assignQuote(project, quoteId, { id: superAdmin, roleName: 'super_admin' });
const ev = await db('events').where({ id: victimEvent }).first('project_id');
expect(Number(ev.project_id)).toBe(Number(project));
});
it('resolves the role from a bare admin id (quote/contract create+update paths)', async () => {
// Those services thread `adminId`, not req.admin — the lookup must still
// scope them, and must fail closed rather than assume super_admin.
const victimEvent = await mkEvent('bare-gala', editorB);
const quoteId = await mkQuote('deal-bare', victimEvent);
const project = await mkProject('bare-project', editorA);
await expect(
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,149 @@
/**
* getProjectOverview stamps each email with `canAct` — whether the queued-mail
* routes (requireOwnedQueuedEmail) would actually accept an action on it.
*
* The cockpit used to derive this client-side from `event_id != null`, which is
* weaker than the backend rule in a way that still produced dead controls:
* requireOwnedQueuedEmail ALSO requires ownership of that event, while
* getProjectOverview lists the project's events by project_id alone. Project
* ownership does not imply event ownership — ownedProjectsSubquery's
* `projects.created_by = admin.id` branch places no constraint on the linked
* events' owners, so a super_admin can attach admin B's event to admin A's
* project. See #969 / codex review round 1.
*/
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-canact-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'canact-test-secret';
const bcrypt = require('bcrypt');
const { bootCrmDb, seedMinimal } = require('../integration/helpers/crmDb');
describe('getProjectOverview email canAct (#969)', () => {
let db; let cleanup; let projectService;
let adminA; let adminB; let superAdmin;
let projectId; let ownEventId; let foreignEventId; let ownerlessEventId;
const mkAdmin = async (username, roleName) => {
const role = await db('roles').where({ name: roleName }).first();
const r = await db('admin_users').insert({
username, email: `${username}@example.com`,
password_hash: await bcrypt.hash('Passw0rd!', 4),
role_id: role.id, is_active: 1,
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkEvent = async (slug, createdBy, project) => {
const r = await db('events').insert({
slug, event_type: 'wedding', event_name: slug, event_date: '2026-08-01',
host_email: 'h@e.com', admin_email: 'a@e.com', password_hash: 'x',
share_token: `t-${slug}`, share_link: `/g/${slug}/t-${slug}`,
created_by: createdBy, project_id: project,
expires_at: new Date(Date.now() + 864e5).toISOString(),
is_active: 1, is_archived: 0, is_draft: 0,
created_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
const mkMail = async (eventId, type) => {
const r = await db('email_queue').insert({
recipient_email: 'kunde@example.com', email_type: type, status: 'sent',
event_id: eventId,
created_at: new Date().toISOString(), sent_at: new Date().toISOString(),
}).returning('id');
return r[0]?.id ?? r[0];
};
beforeAll(async () => {
({ db, cleanup } = await bootCrmDb());
await seedMinimal(db);
projectService = require('../../src/services/projectService');
adminA = await mkAdmin('canact-a', 'editor');
adminB = await mkAdmin('canact-b', 'editor');
superAdmin = await mkAdmin('canact-root', 'super_admin');
const p = await db('projects').insert({
name: 'Cockpit canAct', status: 'active', created_by: adminA,
created_at: new Date().toISOString(), updated_at: new Date().toISOString(),
}).returning('id');
projectId = p[0]?.id ?? p[0];
// All three hang off adminA's project. Only the first is adminA's; the
// third is an ownerless legacy row, which filterOwnedEventIds treats as
// owned by whoever asks — but only once we know who is asking.
ownEventId = await mkEvent('canact-own', adminA, projectId);
foreignEventId = await mkEvent('canact-foreign', adminB, projectId);
ownerlessEventId = await mkEvent('canact-legacy', null, projectId);
await mkMail(ownEventId, 'gallery_ready');
await mkMail(foreignEventId, 'gallery_ready');
await mkMail(ownerlessEventId, 'gallery_ready');
}, 120000);
afterAll(async () => { if (cleanup) await cleanup(); });
const byEvent = (overview) => {
const m = new Map();
for (const e of overview.emails) m.set(e.eventId, e);
return m;
};
it('clears mail on an event the caller owns', async () => {
const overview = await projectService.getProjectOverview(
projectId, {}, { id: adminA, roleName: 'editor' },
);
expect(byEvent(overview).get(ownEventId).canAct).toBe(true);
});
it('denies mail on a foreign admin\'s event inside the caller\'s own project', async () => {
const overview = await projectService.getProjectOverview(
projectId, {}, { id: adminA, roleName: 'editor' },
);
// event_id is non-null here — the old client-side rule would have offered
// controls, and requireOwnedQueuedEmail would have 404'd them.
const row = byEvent(overview).get(foreignEventId);
expect(row.eventId).not.toBeNull();
expect(row.canAct).toBe(false);
});
it('clears everything for a super_admin', async () => {
const overview = await projectService.getProjectOverview(
projectId, {}, { id: superAdmin, roleName: 'super_admin' },
);
expect(overview.emails.every((e) => e.canAct === true)).toBe(true);
});
it('clears mail on an ownerless legacy event for an identified caller', async () => {
// Parity with filterOwnedEventIds, which allows created_by IS NULL.
const overview = await projectService.getProjectOverview(
projectId, {}, { id: adminA, roleName: 'editor' },
);
expect(byEvent(overview).get(ownerlessEventId).canAct).toBe(true);
});
it('denies everything when no admin context is supplied', async () => {
// Including the ownerless event: `created_by == null` must not read as
// "owned" when we do not know who is asking (codex review round 2).
const overview = await projectService.getProjectOverview(projectId, {});
expect(overview.emails.length).toBe(3);
expect(overview.emails.every((e) => e.canAct === false)).toBe(true);
});
it('does not leak event ownership to the client', async () => {
const overview = await projectService.getProjectOverview(
projectId, {}, { id: adminA, roleName: 'editor' },
);
expect(overview.events.length).toBe(3);
for (const e of overview.events) expect(e).not.toHaveProperty('created_by');
});
});
@@ -0,0 +1,91 @@
/**
* Brand-token substitution must not reintroduce markup after sanitization
* (GHSA-j347).
*
* buildCachedPayload sanitizes the operator's HTML and THEN calls
* applyBrandTokens on the result, which did a plain `String.replace` with no
* escaping. The default templates interpolate tokens into text and into quoted
* attributes (`<img src="{{brand_logo_url}}" alt="{{company_name}} logo">`,
* `href="mailto:{{support_email}}"`), so a token value could close the
* attribute and inject markup into the public origin.
*
* The writer is settings.edit (super_admin only) and the CSP blocks inline
* script, so this is defence-in-depth rather than a live RCE — but the
* sanitize-then-substitute ordering is a real bug either way.
*/
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-brandtok-')), 'db.sqlite',
);
process.env.JWT_SECRET = process.env.JWT_SECRET || 'brandtok-test-secret';
const { _internal } = require('../../src/services/publicSiteService');
// applyBrandTokens / sanitizeBrandUrl are module-private; the service exports
// them under _internal for testing (see publicSiteService module.exports).
const { applyBrandTokens, sanitizeBrandUrl } = _internal || {};
const maybe = applyBrandTokens ? describe : describe.skip;
maybe('applyBrandTokens escaping (GHSA-j347)', () => {
it('escapes markup in a text-position token', () => {
const out = applyBrandTokens('<p>{{company_name}}</p>', {
companyName: '<script>alert(1)</script>',
});
expect(out).not.toContain('<script>');
expect(out).toContain('&lt;script&gt;');
});
it('escapes a quote that would break out of an attribute', () => {
const out = applyBrandTokens(
'<img src="/x.png" alt="{{company_name}} logo">',
{ companyName: '" onerror="alert(1)' },
);
// The injected quotes must be entity-encoded, so the payload stays INSIDE
// the alt value as text instead of terminating it and forming a real
// onerror attribute. (`onerror=` still appears as literal characters —
// that is inert; what matters is that no raw `"` closed the attribute.)
expect(out).not.toContain('" onerror="');
expect(out).toContain('&quot; onerror=&quot;');
});
it('escapes the logo url token used inside src="..."', () => {
const out = applyBrandTokens('<img src="{{brand_logo_url}}">', {
logoUrl: '" onerror="alert(1)',
});
expect(out).not.toContain('" onerror="');
expect(out).toContain('&quot;');
});
it('leaves ordinary values readable', () => {
const out = applyBrandTokens('<p>{{company_name}}</p>', { companyName: 'Acme Photos' });
expect(out).toContain('Acme Photos');
});
});
const maybeUrl = sanitizeBrandUrl ? describe : describe.skip;
maybeUrl('sanitizeBrandUrl scheme allowlist (GHSA-j347)', () => {
it('rejects javascript: regardless of case', () => {
expect(sanitizeBrandUrl('javascript:alert(1)')).toBeNull();
// The old check was a case-sensitive startsWith and missed these.
expect(sanitizeBrandUrl('JavaScript:alert(1)')).toBeNull();
expect(sanitizeBrandUrl(' JAVASCRIPT:alert(1)')).toBeNull();
});
it('rejects other non-http schemes', () => {
expect(sanitizeBrandUrl('data:text/html;base64,PHN2Zz4=')).toBeNull();
expect(sanitizeBrandUrl('vbscript:msgbox(1)')).toBeNull();
});
it('keeps http(s) and relative logo paths working', () => {
expect(sanitizeBrandUrl('https://cdn.example.com/logo.png'))
.toBe('https://cdn.example.com/logo.png');
expect(sanitizeBrandUrl('/uploads/logos/logo.png')).toBe('/uploads/logos/logo.png');
});
});
@@ -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/);
});
});
Binary file not shown.
@@ -0,0 +1,128 @@
/**
* DNS-resolving SSRF guard (GHSA SSRF cluster: webhook / S3 / rsync / SMTP /
* IMAP). The literal isPrivateIP check can't see that a public-looking
* hostname resolves to an internal/metadata IP; isHostAllowed resolves the
* name and vets every A/AAAA record.
*/
jest.mock('dns', () => {
const actual = jest.requireActual('dns');
return { ...actual, promises: { ...actual.promises, lookup: jest.fn() } };
});
const dns = require('dns');
const {
isHostAllowed,
validateExternalUrlAsync,
classifyHost,
} = require('../../src/utils/networkValidation');
const lookup = dns.promises.lookup;
describe('classifyHost', () => {
beforeEach(() => lookup.mockReset());
it('distinguishes private, unresolved, ok, and invalid', async () => {
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
expect(await classifyHost('evil.example')).toBe('private');
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
expect(await classifyHost('example.com')).toBe('ok');
lookup.mockRejectedValue(new Error('EAI_AGAIN'));
expect(await classifyHost('blip.example')).toBe('unresolved');
lookup.mockResolvedValue([]);
expect(await classifyHost('empty.example')).toBe('unresolved');
expect(await classifyHost('')).toBe('invalid');
expect(await classifyHost('10.0.0.1')).toBe('private'); // literal, no lookup
});
});
describe('isHostAllowed', () => {
beforeEach(() => lookup.mockReset());
it('rejects a public hostname that resolves to a private IP', async () => {
lookup.mockResolvedValue([{ address: '10.0.0.5', family: 4 }]);
expect(await isHostAllowed('evil.example.com')).toBe(false);
});
it('rejects when the hostname resolves to the cloud metadata IP', async () => {
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
expect(await isHostAllowed('metadata-rebind.example')).toBe(false);
});
it('rejects when ANY resolved address is private (rebinding / mixed records)', async () => {
lookup.mockResolvedValue([
{ address: '93.184.216.34', family: 4 },
{ address: '169.254.169.254', family: 4 },
]);
expect(await isHostAllowed('rebind.example')).toBe(false);
});
it('allows a hostname that resolves only to public IPs', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
expect(await isHostAllowed('example.com')).toBe(true);
});
it('fails closed when resolution errors', async () => {
lookup.mockRejectedValue(new Error('ENOTFOUND'));
expect(await isHostAllowed('nxdomain.invalid')).toBe(false);
});
it('fails closed on an empty resolution', async () => {
lookup.mockResolvedValue([]);
expect(await isHostAllowed('empty.example')).toBe(false);
});
it('rejects literal private IPs and blocked names without resolving', async () => {
expect(await isHostAllowed('127.0.0.1')).toBe(false);
expect(await isHostAllowed('10.0.0.1')).toBe(false);
expect(await isHostAllowed('localhost')).toBe(false);
expect(await isHostAllowed('metadata.google.internal')).toBe(false);
expect(await isHostAllowed('foo.internal')).toBe(false);
expect(lookup).not.toHaveBeenCalled();
});
it('allows a public IP literal without resolving', async () => {
expect(await isHostAllowed('93.184.216.34')).toBe(true);
expect(lookup).not.toHaveBeenCalled();
});
it('rejects empty / non-string input', async () => {
expect(await isHostAllowed('')).toBe(false);
expect(await isHostAllowed(null)).toBe(false);
});
});
describe('validateExternalUrlAsync', () => {
beforeEach(() => lookup.mockReset());
it('rejects a URL whose host resolves to a private address', async () => {
lookup.mockResolvedValue([{ address: '10.1.2.3', family: 4 }]);
const r = await validateExternalUrlAsync('https://evil.example/hook');
expect(r.valid).toBe(false);
});
it('accepts a URL whose host resolves public', async () => {
lookup.mockResolvedValue([{ address: '93.184.216.34', family: 4 }]);
expect((await validateExternalUrlAsync('https://example.com/hook')).valid).toBe(true);
});
it('rejects a malformed URL', async () => {
expect((await validateExternalUrlAsync('not a url')).valid).toBe(false);
});
it('reports reason=unresolved for a transient lookup failure (retryable)', async () => {
lookup.mockRejectedValue(new Error('EAI_AGAIN'));
const r = await validateExternalUrlAsync('https://blip.example/hook');
expect(r.valid).toBe(false);
expect(r.reason).toBe('unresolved');
});
it('reports reason=private for a resolved-private host (permanent)', async () => {
lookup.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
const r = await validateExternalUrlAsync('https://rebind.example/hook');
expect(r.valid).toBe(false);
expect(r.reason).toBe('private');
});
});
@@ -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,40 @@
/**
* Unit tests for the shared hidden-photo access-control helper.
*
* Pins the rule that ordinary gallery guests never receive photos with
* visibility='hidden' (NULL = visible), while PIN-clients see everything.
*/
const {
canSeeHiddenPhotos,
isPhotoHiddenFromViewer,
} = require('../../src/utils/photoVisibility');
describe('canSeeHiddenPhotos', () => {
it('is true only for the client access level', () => {
expect(canSeeHiddenPhotos('client')).toBe(true);
expect(canSeeHiddenPhotos('guest')).toBe(false);
expect(canSeeHiddenPhotos('slideshow')).toBe(false);
expect(canSeeHiddenPhotos(undefined)).toBe(false);
});
});
describe('isPhotoHiddenFromViewer', () => {
it('blocks a hidden photo from guests', () => {
expect(isPhotoHiddenFromViewer({ visibility: 'hidden' }, 'guest')).toBe(true);
expect(isPhotoHiddenFromViewer({ visibility: 'hidden' }, 'slideshow')).toBe(true);
});
it('lets clients see hidden photos', () => {
expect(isPhotoHiddenFromViewer({ visibility: 'hidden' }, 'client')).toBe(false);
});
it('treats visible and NULL visibility as viewable by everyone', () => {
expect(isPhotoHiddenFromViewer({ visibility: 'visible' }, 'guest')).toBe(false);
expect(isPhotoHiddenFromViewer({ visibility: null }, 'guest')).toBe(false);
expect(isPhotoHiddenFromViewer({}, 'guest')).toBe(false);
});
it('is null-safe', () => {
expect(isPhotoHiddenFromViewer(null, 'guest')).toBe(false);
});
});
@@ -99,10 +99,22 @@ describe('resolveLogoFile', () => {
}
});
it('treats absolute paths as-is when they exist', async () => {
it('rejects an absolute path OUTSIDE the storage roots (GHSA-c7x5)', async () => {
// The raw-absolute candidate was an arbitrary-file-read primitive
// (logo_path: '/etc/passwd' → rasterised into a PDF). Absolute paths
// outside the storage roots are now dropped even if they exist.
existsSpy.mockImplementation((p) => p === '/abs/path/logo.png');
getAppSetting.mockResolvedValue(null);
const out = await resolveLogoFile({ logo_path: '/abs/path/logo.png' });
expect(out).toBe('/abs/path/logo.png');
expect(out).toBeNull();
});
it('still accepts an absolute path INSIDE the storage root', async () => {
// The legitimate case: multer stores the uploaded logo under
// storage/uploads/logos with an absolute path — that stays resolvable.
existsSpy.mockImplementation((p) => p === '/app/storage/uploads/logos/logo.png');
getAppSetting.mockResolvedValue(null);
const out = await resolveLogoFile({ logo_path: '/app/storage/uploads/logos/logo.png' });
expect(out).toBe('/app/storage/uploads/logos/logo.png');
});
});
@@ -0,0 +1,96 @@
/**
* Credential redaction for log payloads (GHSA-pgmp / GHSA-r794).
*
* Event create/update logged the whole request body. Beyond the plaintext
* gallery password named in the advisories, the update path also logged
* `client_share_token` — a LIVE bearer credential for client gallery access,
* freshly minted by `regenerate_client_token` — and `client_password_hash`.
*/
const { sanitizeForLog, isSensitiveKey } = require('../../src/utils/sanitizeForLog');
describe('sanitizeForLog', () => {
it('redacts the credentials an event body actually carries', () => {
const out = sanitizeForLog({
event_name: 'Wedding',
password: 'FAKE-PLAINTEXT-PASSWORD',
client_password: 'FAKE-CLIENT-PASSWORD',
client_password_hash: 'FAKE-BCRYPT-HASH-PLACEHOLDER',
client_share_token: 'FAKE-CLIENT-SHARE-TOKEN',
share_token: 'FAKE-SHARE-TOKEN',
});
expect(out.event_name).toBe('Wedding');
for (const key of ['password', 'client_password', 'client_password_hash',
'client_share_token', 'share_token']) {
expect(out[key]).toBe('[redacted]');
}
expect(JSON.stringify(out)).not.toContain('FAKE-PLAINTEXT-PASSWORD');
expect(JSON.stringify(out)).not.toContain('FAKE-CLIENT-SHARE-TOKEN');
});
it('redacts nested and array-nested secrets', () => {
const out = sanitizeForLog({
smtp: { host: 'mail.example.com', smtp_password: 'p' },
users: [{ name: 'a', api_key: 'k' }],
});
expect(out.smtp.host).toBe('mail.example.com');
expect(out.smtp.smtp_password).toBe('[redacted]');
expect(out.users[0].name).toBe('a');
expect(out.users[0].api_key).toBe('[redacted]');
});
it('passes non-objects through and survives cycles', () => {
expect(sanitizeForLog('plain')).toBe('plain');
expect(sanitizeForLog(42)).toBe(42);
expect(sanitizeForLog(null)).toBeNull();
const cyclic = { name: 'x' };
cyclic.self = cyclic;
expect(() => sanitizeForLog(cyclic)).not.toThrow();
expect(sanitizeForLog(cyclic).self).toBe('[circular]');
});
it('matches key names case-insensitively and by fragment', () => {
expect(isSensitiveKey('Authorization')).toBe(true);
expect(isSensitiveKey('CLIENT_SHARE_TOKEN')).toBe(true);
expect(isSensitiveKey('event_name')).toBe(false);
});
});
/**
* Codex round 2: sanitizing req.body was not enough. express-validator's
* errors.array() embeds the SUBMITTED value per field, so a password rejected
* for being too short was still logged in plaintext.
*/
describe('sanitizeValidationErrors', () => {
const { sanitizeValidationErrors } = require('../../src/utils/sanitizeForLog');
it('redacts the submitted value for a password field', () => {
const out = sanitizeValidationErrors([
{ type: 'field', path: 'password', msg: 'too short', value: 'FAKE-PLAINTEXT-PASSWORD' },
{ type: 'field', path: 'event_name', msg: 'required', value: '' },
]);
expect(out[0].value).toBe('[redacted]');
expect(out[0].msg).toBe('too short');
expect(JSON.stringify(out)).not.toContain('FAKE-PLAINTEXT-PASSWORD');
expect(out[1].value).toBe('');
});
it('handles the legacy `param` field name', () => {
const out = sanitizeValidationErrors([{ param: 'client_password', value: 'FAKE-SECRET' }]);
expect(out[0].value).toBe('[redacted]');
});
it('recurses into object values on non-sensitive fields', () => {
const out = sanitizeValidationErrors([
{ path: 'config', value: { host: 'h', api_key: 'k' } },
]);
expect(out[0].value.host).toBe('h');
expect(out[0].value.api_key).toBe('[redacted]');
});
it('passes non-arrays through untouched', () => {
expect(sanitizeValidationErrors(undefined)).toBeUndefined();
});
});
@@ -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,71 @@
/**
* Migration 167: give `projects` a first-class owner (GHSA-wrg5).
*
* Project routes authorize on generic `events.view` / `events.edit` only, with
* no ownership check, so an editor-like admin could enumerate, read, update
* and aggregate projects belonging to other admins' events.
*
* Ownership IS derivable transitively — `events.project_id` (migration 117)
* plus `events.created_by` (migration 060) — but only for projects that have
* at least one linked event. A freshly created, still-empty project has no
* derivable owner, which would leave a hole exactly where the create → attach
* flow starts. Storing the creator removes that ambiguity: projectService
* already receives `adminId` in createProject() and simply discarded it.
*
* Backfill uses the transitive path, which is well-defined here: migration 117
* created exactly one auto-project per pre-existing event, so those projects
* map 1:1 to an owning event. Projects with no linked event (or whose events
* are themselves ownerless legacy rows) stay NULL and are treated as
* unowned//legacy by the ownership helper — same convention the events table
* already uses for `created_by IS NULL`.
*
* down() drops the column; the derived data is reconstructible by re-running
* the same backfill, so nothing is lost irreversibly.
*/
exports.up = async function (knex) {
if (!(await knex.schema.hasTable('projects'))) return;
if (!(await knex.schema.hasColumn('projects', 'created_by'))) {
await knex.schema.alterTable('projects', (t) => {
// No FK constraint: admin_users rows can be removed, and orphaning a
// project would be worse than a dangling id (which reads as unowned).
t.integer('created_by').nullable();
});
}
// Backfill from the linked events, only where we can determine it
// unambiguously (every owning event agrees on a single non-null creator).
if (await knex.schema.hasColumn('events', 'project_id')
&& await knex.schema.hasColumn('events', 'created_by')) {
const rows = await knex('events')
.whereNotNull('project_id')
.whereNotNull('created_by')
.select('project_id', 'created_by')
.groupBy('project_id', 'created_by');
const byProject = new Map();
for (const row of rows) {
const list = byProject.get(row.project_id) || [];
list.push(row.created_by);
byProject.set(row.project_id, list);
}
for (const [projectId, creators] of byProject) {
// Ambiguous (events from two different admins) → leave NULL rather than
// guess an owner and hand one admin authority over another's work.
if (creators.length !== 1) continue;
await knex('projects')
.where({ id: projectId })
.whereNull('created_by')
.update({ created_by: creators[0] });
}
}
};
exports.down = async function (knex) {
if (!(await knex.schema.hasTable('projects'))) return;
if (await knex.schema.hasColumn('projects', 'created_by')) {
await knex.schema.alterTable('projects', (t) => t.dropColumn('created_by'));
}
};
@@ -0,0 +1,64 @@
/**
* GHSA-jhcf — data correction for legacy accounting activity rows.
*
* expenseService called `logActivity(type, metadata, adminId)`, but the third
* positional parameter of logActivity is `eventId`, not the actor. Every
* expense / incoming-invoice entry therefore stored the ACTING ADMIN'S ID in
* `activity_logs.event_id` (and no actor at all).
*
* That is not merely cosmetic. The dashboard activity feed now scopes rows via
* `WHERE activity_logs.event_id IN (SELECT id FROM events WHERE created_by = me)`.
* Admin ids and event ids are both small integers drawn from the same range, so
* on any upgraded instance an editor who happens to own the event whose id
* equals another admin's id is served that admin's accounting activity —
* verbatim metadata included. Scoping new writes correctly does nothing for the
* rows already on disk, so they are corrected here.
*
* The stored value is exactly the actor id we lost, so this re-attributes
* rather than discards: event_id → actor_id (when no actor was recorded), then
* event_id is cleared so the scope predicate can no longer match it.
*
* All ten activity types below are emitted by expenseService and nothing else,
* so no row with a genuine event_id is touched.
*/
const AFFECTED_TYPES = [
'incoming_invoice_captured',
'incoming_invoice_updated',
'incoming_invoice_categorized',
'incoming_invoice_rebilled',
'incoming_invoices_rebilled_bundle',
'incoming_invoice_supplier_payment',
'expense_created',
'expense_updated',
'expense_invoiced',
'expense_paid',
];
exports.up = async function up(knex) {
if (!(await knex.schema.hasTable('activity_logs'))) return;
if (!(await knex.schema.hasColumn('activity_logs', 'event_id'))) return;
const hasActorId = await knex.schema.hasColumn('activity_logs', 'actor_id');
const hasActorType = await knex.schema.hasColumn('activity_logs', 'actor_type');
if (hasActorId) {
const patch = { actor_id: knex.ref('event_id') };
if (hasActorType) patch.actor_type = 'admin';
await knex('activity_logs')
.whereIn('activity_type', AFFECTED_TYPES)
.whereNotNull('event_id')
.whereNull('actor_id')
.update(patch);
}
await knex('activity_logs')
.whereIn('activity_type', AFFECTED_TYPES)
.whereNotNull('event_id')
.update({ event_id: null });
};
// Irreversible by design: this is a data correction, and the pre-migration
// state is a cross-admin disclosure. Re-planting admin ids in event_id would
// reopen GHSA-jhcf.
exports.down = async function down() {};
@@ -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');
+52 -56
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "3.45.6",
"version": "3.45.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "3.45.6",
"version": "3.45.14",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -42,13 +42,14 @@
"node-stream-zip": "^1.15.0",
"nodemailer": "^9.0.1",
"otplib": "^12.0.1",
"p-limit": "^3.1.0",
"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.0",
"sanitize-html": "2.17.5",
"sharp": "0.35.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
@@ -4498,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"
@@ -5226,6 +5227,12 @@
"integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
"license": "MIT"
},
"node_modules/dayjs": {
"version": "1.11.21",
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz",
"integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==",
"license": "MIT"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -6785,19 +6792,7 @@
"url": "https://github.com/sponsors/KillyMXI"
}
},
"node_modules/html-to-text/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/html-to-text/node_modules/htmlparser2": {
"node_modules/htmlparser2": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
"integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
@@ -6816,23 +6811,16 @@
"entities": "^7.0.1"
}
},
"node_modules/htmlparser2": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz",
"integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==",
"funding": [
"https://github.com/fb55/htmlparser2?sponsor=1",
{
"type": "github",
"url": "https://github.com/sponsors/fb55"
}
],
"license": "MIT",
"dependencies": {
"domelementtype": "^2.3.0",
"domhandler": "^5.0.3",
"domutils": "^3.0.1",
"entities": "^4.4.0"
"node_modules/htmlparser2/node_modules/entities": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/http-cache-semantics": {
@@ -7116,9 +7104,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"
@@ -7993,9 +7981,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",
@@ -8232,6 +8220,15 @@
"integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==",
"license": "MIT"
},
"node_modules/launder": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz",
"integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==",
"license": "MIT",
"dependencies": {
"dayjs": "^1.11.7"
}
},
"node_modules/lazystream": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz",
@@ -9079,9 +9076,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",
@@ -9558,7 +9555,6 @@
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
"integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"yocto-queue": "^0.1.0"
@@ -10036,9 +10032,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",
@@ -10055,7 +10051,7 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.12",
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
@@ -10846,15 +10842,16 @@
"license": "MIT"
},
"node_modules/sanitize-html": {
"version": "2.17.0",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.0.tgz",
"integrity": "sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA==",
"version": "2.17.5",
"resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.5.tgz",
"integrity": "sha512-ZmU1joGRrvoyctKIiuwUxqR6moLoU2Wk+2bMccN6f7UwhAmwYDvWziqPxRDDN2Qip62NqnIrVrT9akbL6Wretg==",
"license": "MIT",
"dependencies": {
"deepmerge": "^4.2.2",
"escape-string-regexp": "^4.0.0",
"htmlparser2": "^8.0.0",
"htmlparser2": "^10.1.0",
"is-plain-object": "^5.0.0",
"launder": "^1.7.1",
"parse-srcset": "^1.0.2",
"postcss": "^8.3.11"
}
@@ -12590,7 +12587,6 @@
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
"integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
+6 -5
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.45.8",
"version": "3.46.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"engines": {
@@ -51,13 +51,14 @@
"node-stream-zip": "^1.15.0",
"nodemailer": "^9.0.1",
"otplib": "^12.0.1",
"p-limit": "^3.1.0",
"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.0",
"sanitize-html": "2.17.5",
"sharp": "0.35.3",
"sqlite3": "^5.1.6",
"swagger-jsdoc": "^6.2.8",
@@ -84,13 +85,13 @@
"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"
}
@@ -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) {
+66 -4
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()
});
@@ -1005,8 +1055,15 @@ async function startServer() {
// Runs AFTER install-from-backup so a restored instance (which repopulates
// admin_users) never prints a throwaway token. Best-effort — never blocks boot.
let setupToken = null;
let setupTokenFile = null;
try {
setupToken = await require('./src/services/setupService').ensureSetupToken();
const setupSvc = require('./src/services/setupService');
setupToken = await setupSvc.ensureSetupToken();
// The path the write ACTUALLY produced (null when it failed). existsSync
// on the candidate answered a different question and reported success
// for a stale, read-only or directory-shaped SETUP_TOKEN — suppressing
// the token here while pointing the operator at content that is not it.
setupTokenFile = setupSvc.writtenSetupTokenFile();
} catch (err) {
logger.warn(`[setup] ensureSetupToken skipped: ${err.message}`);
}
@@ -1026,12 +1083,17 @@ async function startServer() {
logger.info(`Server running on port ${PORT}`);
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
logger.info(`Frontend: ${process.env.FRONTEND_URL || 'http://localhost:3001'}`);
// First-run: print the one-time setup token to STDOUT (the file logger
// doesn't reach `docker logs`), as the last + most visible thing at boot.
// First-run banner. Print the TOKEN ITSELF only when the 0600 token file
// could not be written — otherwise this lands a live first-admin
// credential in `docker logs` / journald, which is the leak GHSA-r794's
// sweep turned up. When the file exists we point at it instead.
if (setupToken) {
const url = `${process.env.ADMIN_URL || 'http://localhost:3000'}/admin`;
const line = '='.repeat(64);
console.log(`\n${line}\n PicPeak first-run setup — no admin account yet.\n Open: ${url}\n One-time setup token: ${setupToken}\n (also saved to data/SETUP_TOKEN)\n${line}\n`);
const secretLine = setupTokenFile
? ` Setup token saved to: ${setupTokenFile}\n (read it there — deliberately not printed)`
: ` One-time setup token: ${setupToken}\n (could not write the token file, so it is shown here)`;
console.log(`\n${line}\n PicPeak first-run setup — no admin account yet.\n Open: ${url}\n${secretLine}\n${line}\n`);
}
});
} catch (error) {
@@ -52,7 +52,11 @@ describe('publicSiteService', () => {
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.enabled).toBe(true);
expect(payload.html).toContain('<h1>Willow & Pine Studio</h1>');
// Brand tokens are HTML-escaped on substitution now (GHSA-j347), so a bare
// `&` in the company name is emitted as the `&amp;` entity. That renders
// identically in a browser — it is the correctly-encoded form — but the raw
// payload string differs from the pre-fix output.
expect(payload.html).toContain('<h1>Willow &amp; Pine Studio</h1>');
expect(payload.html).not.toContain('<script');
expect(payload.baseCss.length).toBeGreaterThan(0);
expect(payload.branding.companyName).toBe('Willow & Pine Studio');
+42 -5
View File
@@ -1,5 +1,7 @@
const crypto = require('crypto');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isMissingRolesSchema } = require('../utils/dbErrors');
const logger = require('../utils/logger');
const TOKEN_PREFIX = 'pp_live_';
@@ -63,10 +65,36 @@ async function apiTokenAuth(req, res, next) {
return res.status(401).json({ error: 'Token expired', code: 'TOKEN_EXPIRED' });
}
const admin = await db('admin_users')
.where({ id: row.created_by, is_active: true })
.select('id', 'username', 'email', 'role_id')
.first();
// Load the owner WITH their role name (GHSA-9697). Without it,
// req.admin.roleName was undefined — and every ownership check keys on
// roleName — so the v1 surface could not tell a super_admin from a
// demoted viewer. Mirrors adminAuth's shape, including the
// roles-table-missing fallback used during upgrades.
let admin;
try {
admin = await db('admin_users')
.leftJoin('roles', 'roles.id', 'admin_users.role_id')
.where({ 'admin_users.id': row.created_by, 'admin_users.is_active': formatBoolean(true) })
.select(
'admin_users.id',
'admin_users.username',
'admin_users.email',
'roles.id as role_id',
'roles.name as role_name'
)
.first();
} catch (joinError) {
// Fail CLOSED on anything that isn't a genuinely missing roles schema:
// the fallback fabricates super_admin, so a transient query failure must
// not become a free privilege upgrade. Rethrow → outer catch → 500.
if (!isMissingRolesSchema(joinError)) throw joinError;
logger.debug('Roles table not available in apiTokenAuth', { error: joinError.message });
admin = await db('admin_users')
.where({ id: row.created_by, is_active: formatBoolean(true) })
.select('id', 'username', 'email', 'role_id')
.first();
if (admin) admin.role_name = 'super_admin'; // upgrade-path parity with adminAuth
}
if (!admin) {
return res.status(401).json({ error: 'Token owner unavailable', code: 'OWNER_INACTIVE' });
}
@@ -75,7 +103,15 @@ async function apiTokenAuth(req, res, next) {
db('api_tokens').where({ id: row.id }).update({ last_used_at: new Date() })
.catch((err) => logger.debug('api_tokens last_used update failed', { err: err.message }));
req.admin = admin;
// Same shape adminAuth produces, so requirePermission / ownership helpers
// behave identically whether the caller used a session or an API token.
req.admin = {
id: admin.id,
username: admin.username,
email: admin.email,
roleId: admin.role_id,
roleName: admin.role_name
};
req.apiToken = {
id: row.id,
name: row.name,
@@ -118,6 +154,7 @@ module.exports = {
generateApiToken,
hashToken,
parseScopes,
isMissingRolesSchema,
TOKEN_PREFIX,
VALID_SCOPES
};
+9
View File
@@ -1,6 +1,7 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isMissingRolesSchema } = require('../utils/dbErrors');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
@@ -75,6 +76,14 @@ async function adminAuth(req, res, next) {
)
.first();
} catch (joinError) {
// Fail CLOSED on anything that isn't a genuinely missing roles schema:
// the fallback below fabricates super_admin, so a transient query failure
// (connection reset, deadlock, statement timeout, pool exhaustion) must
// not become a free privilege upgrade for every scoped admin. Rethrow →
// outer catch → 401, which is already how a transient DB fault in this
// try block behaves (isTokenRevoked hits the DB here). apiTokenAuth takes
// the same posture on the v1 surface, differing only in its 500.
if (!isMissingRolesSchema(joinError)) throw joinError;
// Fallback: roles table may not exist yet during upgrade
// Query without role join - user will have no role info but can still authenticate
logger.debug('Roles table not available, falling back to basic auth', { error: joinError.message });
+99 -1
View File
@@ -32,6 +32,20 @@ function requireEventOwnership(req, res, next) {
});
}
/**
* Apply the ownership predicate to a knex query over `events`, for list
* endpoints that can't use requireEventOwnership (no :id to check).
* super_admin is unrestricted; everyone else sees ownerless (legacy/system)
* events plus their own — the same rule requireEventOwnership enforces
* per-row.
*/
function scopeEventsQuery(query, admin, column = 'created_by') {
if (admin?.roleName === 'super_admin') {
return query;
}
return query.where((q) => q.whereNull(column).orWhere(column, admin.id));
}
/**
* Return the subset of `eventIds` the admin may act on, mirroring
* requireEventOwnership for bulk routes that can't use it (they take an
@@ -64,4 +78,88 @@ async function filterOwnedEventIds(admin, eventIds) {
return { allowed, denied };
}
module.exports = { requireEventOwnership, filterOwnedEventIds };
/**
* Knex subquery selecting the ids of projects `admin` may act on, or `null`
* when the caller is unrestricted (GHSA-wrg5).
*
* Rules, in priority order:
* 1. A project's STORED owner is authoritative. If `projects.created_by` is
* set to a live admin, only that admin (and super_admin) may act on it.
* Earlier this union'd in "any linked event I can see", which meant one
* legacy ownerless event inside another admin's project exposed the whole
* project — its other events, invoices and emails — through the overview.
* 2. Only when there is NO usable stored owner (NULL, or pointing at a
* deleted admin) do we derive from linked events, and then EVERY linked
* event must be accessible: a project the old unrestricted routes filled
* with several admins' events is ambiguous, and migration 167 deliberately
* leaves those NULL. Granting on "any" would have made exactly those
* mixed projects readable by everyone.
* 3. A project with no usable owner AND no linked events (an orphan — not
* creatable since createProject stamps created_by) stays super_admin-only.
* Failing closed beats failing open; a super_admin can reassign it.
*
* Returned as a subquery so callers avoid materialising an id list.
*/
function ownedProjectsSubquery(admin) {
if (admin?.roleName === 'super_admin') return null;
const linkedEvents = () => db('events').select(db.raw('1')).whereRaw('events.project_id = projects.id');
return db('projects').select('projects.id').where((w) => {
w.where('projects.created_by', admin.id)
.orWhere((noOwner) => {
noOwner
// No usable stored owner: NULL, or a creator that no longer exists
// (hard-deleted admin) — otherwise that project would be locked away
// from everyone but super_admin forever.
.where((c) => c
.whereNull('projects.created_by')
.orWhereNotIn('projects.created_by', db('admin_users').select('id')))
.whereExists(linkedEvents())
.whereNotExists(
linkedEvents().whereNotNull('events.created_by').whereNot('events.created_by', admin.id),
);
});
});
}
/**
* Materialised form of ownedProjectsSubquery, for callers that need the ids
* themselves. `null` = unrestricted.
*
* @returns {Promise<number[]|null>}
*/
async function ownedProjectIds(admin) {
const sub = ownedProjectsSubquery(admin);
if (sub === null) return null;
const rows = await sub;
return rows.map((r) => Number(r.id));
}
/**
* Middleware enforcing ownedProjectIds() on a :id project route. 404 (not 403)
* on a foreign project so the endpoint isn't an existence oracle — same
* posture filterOwnedEventIds takes for foreign-vs-missing ids.
*/
function requireProjectOwnership(req, res, next) {
const sub = ownedProjectsSubquery(req.admin);
if (sub === null) return next();
const projectId = Number(req.params.id);
sub.clone()
.where('projects.id', projectId)
.first()
.then((row) => {
if (!row) return res.status(404).json({ error: 'Project not found' });
next();
})
.catch(() => res.status(500).json({ error: 'Failed to verify project ownership' }));
}
module.exports = {
requireEventOwnership,
filterOwnedEventIds,
scopeEventsQuery,
ownedProjectIds,
ownedProjectsSubquery,
requireProjectOwnership,
};
+28 -1
View File
@@ -3,6 +3,7 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
async function photoAuth(req, res, next) {
@@ -89,7 +90,33 @@ async function photoAuth(req, res, next) {
// Check if it's an admin token (admins can view all photos)
if (decoded.type === 'admin') {
// For both thumbnails and photos with admin token, allow access
// Enforce the same revocation / session-cutoff invalidation that
// adminAuth does — otherwise a validly-signed admin JWT keeps
// serving photos after logout, password change, or explicit
// revocation (GHSA-x55x).
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ error: 'Session expired' });
}
// adminAuth also (a) rejects tokens for a now-deactivated admin and
// (b) rejects any token minted before the admin's last password
// change. Token revocation alone doesn't cover those, so without
// these two checks a stale or pre-password-change admin token still
// fetches every photo.
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: formatBoolean(true) })
.select('id', 'password_changed_at')
.first();
if (!admin) {
return res.status(401).json({ error: 'Session expired' });
}
if (admin.password_changed_at) {
const passwordChangedSeconds = Math.floor(
new Date(admin.password_changed_at).getTime() / 1000
);
if (decoded.iat < passwordChangedSeconds) {
return res.status(401).json({ error: 'Session expired' });
}
}
return next();
}
} catch (err) {
+14 -1
View File
@@ -19,7 +19,10 @@ const router = express.Router();
// the plaintext, never recoverable after creation.
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const tokens = await db('api_tokens')
// Scope to the caller's own tokens unless super_admin — the previous
// query returned every admin's token metadata (name/preview/scopes/
// owner) to any settings.view holder (GHSA-jm7j).
const tokensQuery = db('api_tokens')
.leftJoin('admin_users', 'admin_users.id', 'api_tokens.created_by')
.select(
'api_tokens.id',
@@ -33,6 +36,10 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
'admin_users.username as owner_username'
)
.orderBy('api_tokens.created_at', 'desc');
if (req.admin.roleName !== 'super_admin') {
tokensQuery.where('api_tokens.created_by', req.admin.id);
}
const tokens = await tokensQuery;
res.json(tokens);
} catch (error) {
logger.error('Failed to list API tokens', { error: error.message });
@@ -101,6 +108,12 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
const { id } = req.params;
const row = await db('api_tokens').where({ id }).first();
if (!row) return res.status(404).json({ error: 'Token not found' });
// Only the token's owner (or a super_admin) may revoke it — otherwise
// any settings.edit holder could revoke another admin's tokens
// (GHSA-gprq). 404 rather than 403 so a non-owner can't probe token ids.
if (req.admin.roleName !== 'super_admin' && row.created_by !== req.admin.id) {
return res.status(404).json({ error: 'Token not found' });
}
if (row.revoked_at) return res.status(400).json({ error: 'Token already revoked' });
await db('api_tokens').where({ id }).update({ revoked_at: new Date() });
+13 -4
View File
@@ -8,7 +8,7 @@ const { endSession } = require('../middleware/sessionTimeout');
const { validatePasswordStrength } = require('../utils/passwordGenerator');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
const { setAdminAuthCookie } = require('../utils/tokenUtils');
const { setAdminAuthCookie, clearAdminAuthCookie } = require('../utils/tokenUtils');
const { IDENTITY_PRESERVING_NORMALIZE_EMAIL } = require('../utils/emailNormalization');
const mfaService = require('../services/mfaService');
const router = express.Router();
@@ -168,12 +168,21 @@ router.post('/change-password', [
// Logout
router.post('/logout', adminAuth, handleAsync(async (req, res) => {
// Get token from header
const token = req.headers.authorization?.split(' ')[1];
// Use the token adminAuth actually authenticated with (req.token) — it may
// have come from the admin_token cookie, not the Authorization header. The
// old header-only read skipped revocation entirely for cookie-based logout,
// leaving the JWT valid until expiry while reporting a successful logout.
const token = req.token;
if (token) {
// End the session
// End the in-memory session AND revoke the JWT (GHSA-cjqh) — the token
// is otherwise valid until expiry, so photoAuth/adminAuth would keep
// honouring it after logout. isTokenRevoked() checks this store.
endSession(token);
const { revokeToken } = require('../utils/tokenRevocation');
await revokeToken(token, 'logout');
}
// Clear the auth cookie so the browser stops sending the (now revoked) JWT.
clearAdminAuthCookie(res);
// Log activity
await logActivity('admin_logout',
+34 -7
View File
@@ -1,7 +1,7 @@
const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requirePermission, requireSuperAdmin } = require('../middleware/permissions');
const { clearAdminAuthCookie } = require('../utils/tokenUtils');
const { revokeToken } = require('../utils/tokenRevocation');
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
@@ -57,14 +57,33 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
}
break;
case 's3':
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
!updates.backup_s3_access_key || !updates.backup_s3_secret_key) {
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
}
break;
}
}
// SSRF: validate an S3 endpoint whenever one is supplied — NOT only when
// the payload also flips backup_destination_type to 's3'. The PUT
// persists every backup_* field independently, so with S3 already
// selected a caller could PATCH just backup_s3_endpoint to a
// private-resolving host; the management ops (manifest, bucket/file
// browse, cleanup, test-upload) then connect without going through
// testConnection. Prod-only; dev points at localhost MinIO deliberately.
if (process.env.NODE_ENV === 'production'
&& updates.backup_s3_endpoint && updates.backup_s3_endpoint !== '••••••••') {
const rawEndpoint = updates.backup_s3_endpoint;
const withProto = /^https?:\/\//.test(rawEndpoint) ? rawEndpoint : `https://${rawEndpoint}`;
let epHost = null;
try { epHost = new URL(withProto).hostname; } catch { epHost = null; }
const { isHostAllowed } = require('../utils/networkValidation');
if (!epHost || !(await isHostAllowed(epHost))) {
return res.status(400).json({ error: 'S3 endpoint resolves to a private or internal network address' });
}
}
// Update settings
for (const [key, value] of Object.entries(updates)) {
if (key.startsWith('backup_')) {
@@ -139,7 +158,12 @@ router.post('/run', adminAuth, requirePermission('backup.create'), async (req, r
// SECURITY: the file contains plaintext secrets (SMTP password, admin password
// hashes, API keys). The download UI must warn before offering it. We surface
// the flag as a response header too so the client can double-confirm.
router.get('/picpeak/export', adminAuth, requirePermission('backup.create'), async (req, res) => {
// Full-instance export dumps every table unredacted — bcrypt password
// hashes, 2FA columns, and all integration secrets (SMTP/SSO/WhatsApp/
// webhook/S3) in cleartext. The built-in `admin` role holds backup.create,
// but is denied this data everywhere else (config APIs mask secrets as
// ********). Gate the raw dump behind super_admin (GHSA-pv6w-rj34-wj9v).
router.get('/picpeak/export', adminAuth, requireSuperAdmin(), async (req, res) => {
const fsSync = require('fs');
try {
const includePhotos = req.query.includePhotos === 'true' || req.query.includePhotos === '1';
@@ -216,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) {
@@ -351,9 +376,11 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
break;
}
// SSRF protection: block connections to private/internal addresses
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(host)) {
// SSRF protection: resolve the host and block any private/internal
// address. ssh does its own DNS at connect time, so a literal-only
// check let a hostname resolving to an internal IP through (#GHSA-4jh8).
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(host))) {
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
break;
}
+63 -12
View File
@@ -249,33 +249,84 @@ router.get(
const brandingLogoUrl = await getAppSetting('branding_logo_url');
const resolved = await resolveLogoFile(profile);
// GHSA-29vm: report candidates RELATIVE to the storage roots rather than
// echoing absolute container paths and process.cwd(). This endpoint exists
// to answer "which candidate did/didn't exist", which relative paths answer
// just as well without handing out the filesystem layout.
const cwdStorage = path.join(process.cwd(), 'storage');
const relativise = (p) => {
for (const [name, root] of [['STORAGE', storageRoot], ['CWD_STORAGE', cwdStorage]]) {
const rel = path.relative(root, p);
if (rel && !rel.startsWith('..') && !path.isAbsolute(rel)) {
return `<${name}>/${rel.split(path.sep).join('/')}`;
}
}
return path.basename(p);
};
const inspect = (label, raw) => {
const value = (raw || '').toString().trim();
if (!value) return { label, value: null, candidates: [] };
const stripped = value.replace(/^\/+/, '');
const baseName = path.basename(value);
// Mirrors resolveLogoFile's candidate list EXACTLY. It keeps the raw
// absolute value as a candidate (multer stores branding_logo_path
// absolute) and lets the storage-root containment filter reject it when
// it points outside — so the diagnostic must include it too, or a
// legitimately-contained absolute logo shows every candidate as missing
// while resolvedTo names the file.
// The stripped joins (`<ROOT>/<value-minus-leading-slash>`) are gated on
// containment, NOT on path.isAbsolute(). isAbsolute() cannot tell a
// multer disk path from a root-relative URL like `/custom/logo.png`, and
// for the URL form `<STORAGE>/custom/logo.png` is a file the resolver
// genuinely returns — skipping it made this endpoint report "no source
// candidate exists" about a logo that renders fine.
//
// The gate is instead: does the raw value ALREADY resolve inside a
// storage root? If so it is a real disk path, the raw candidate below
// covers it, and the stripped join would only produce a double-prefixed
// path that can never exist while re-embedding the absolute path
// GHSA-29vm exists to stop echoing (redact() strips only the leading
// root, so the inner one would survive).
const valueInsideRoot = path.isAbsolute(value) && [
path.resolve(storageRoot), path.resolve(cwdStorage),
].some((root) => {
const r = path.resolve(value);
return r === root || r.startsWith(root + path.sep);
});
const strippedJoins = valueInsideRoot
? []
: [path.join(storageRoot, stripped), path.join(cwdStorage, stripped)];
const candidates = [
path.isAbsolute(value) ? value : null,
path.join(storageRoot, stripped),
...(path.isAbsolute(value) ? [value] : []),
...strippedJoins,
path.join(storageRoot, 'uploads', 'logos', baseName),
path.join(storageRoot, 'branding', baseName),
path.join(process.cwd(), 'storage', stripped),
path.join(process.cwd(), 'storage', 'uploads', 'logos', baseName),
path.join(process.cwd(), 'storage', 'branding', baseName),
].filter(Boolean);
path.join(cwdStorage, 'uploads', 'logos', baseName),
path.join(cwdStorage, 'branding', baseName),
];
const roots = [path.resolve(storageRoot), path.resolve(cwdStorage)];
const contained = candidates.filter((c) => {
const r = path.resolve(c);
return roots.some((root) => r === root || r.startsWith(root + path.sep));
});
return {
label, value,
candidates: [...new Set(candidates)].map((p) => ({
path: p,
label,
// GHSA-29vm: branding_logo_path is stored absolute by multer, so
// echoing it back handed out the filesystem layout just as the
// candidate paths did. Relativise it the same way.
value: path.isAbsolute(value) ? relativise(value) : value,
candidates: [...new Set(contained)].map((p) => ({
path: relativise(p),
exists: (() => { try { return fs.existsSync(p) && fs.statSync(p).isFile(); } catch { return false; } })(),
})),
};
};
return successResponse(res, {
storageRoot,
cwd: process.cwd(),
resolvedTo: resolved,
// Absolute storageRoot / cwd deliberately omitted (GHSA-29vm); the
// candidate paths below are shown relative to <STORAGE>/<CWD_STORAGE>.
resolvedTo: resolved ? relativise(resolved) : null,
sources: [
inspect('business_profile.logo_path', profile?.logo_path),
inspect('app_settings.branding_logo_path', brandingDiskPath),
+19 -4
View File
@@ -141,8 +141,18 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
.trim()
};
// Update hero_photo_id if provided (including null to clear it)
// Update hero_photo_id if provided (including null to clear it). A
// non-null hero must belong to this category (GHSA-j2f4) — the general
// update path previously wrote it with no membership check at all.
if (Object.prototype.hasOwnProperty.call(req.body, 'hero_photo_id')) {
if (hero_photo_id) {
const heroPhoto = await db('photos')
.where({ id: hero_photo_id, category_id: id })
.first();
if (!heroPhoto) {
return res.status(404).json({ error: 'Photo not found in this category' });
}
}
updateData.hero_photo_id = hero_photo_id || null;
}
@@ -192,11 +202,16 @@ router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
return res.status(404).json({ error: 'Category not found' });
}
// If hero_photo_id is provided, verify it belongs to a photo in this category
// If hero_photo_id is provided, verify the photo actually belongs to
// THIS category — checking existence alone let an admin point a
// category's hero at a photo from a different category or event
// (GHSA-j2f4).
if (hero_photo_id) {
const photo = await db('photos').where('id', hero_photo_id).first();
const photo = await db('photos')
.where({ id: hero_photo_id, category_id: id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
return res.status(404).json({ error: 'Photo not found in this category' });
}
}
+40 -2
View File
@@ -11,6 +11,8 @@ const { body, param, query } = require('express-validator');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireFeatureFlag } = require('../middleware/requireFeatureFlag');
const { filterOwnedEventIds } = require('../middleware/ownership');
const { db } = require('../database/db');
// Hour-entry routes are gated by the hoursLogging master so a direct API hit
// can't read/edit/delete/bill logged hours while the feature is off (the
@@ -528,9 +530,45 @@ router.put('/:id/events', [
body('event_ids.*').isInt({ min: 1 }),
], handleAsync(async (req, res) => {
validateRequest(req);
const customerId = parseInt(req.params.id, 10);
const submitted = req.body.event_ids.map(Number);
// The customer's CURRENT assignments. The "Manage galleries" dialog submits
// the full initial list back — including any events owned by OTHER admins —
// so we need this to tell "retain an existing foreign assignment" apart from
// "newly grant a foreign event".
const existingEventIds = (await db('event_customer_assignments')
.where('customer_account_id', customerId)
.pluck('event_id')).map(Number);
const existingSet = new Set(existingEventIds);
// Events the caller may act on (GHSA-xr6x). A denied id is only acceptable
// when the customer ALREADY has that assignment (a foreign event the caller
// is merely keeping); a denied id that isn't already assigned is a fresh
// attempt to mint access to a foreign/nonexistent event → reject.
const { allowed } = await filterOwnedEventIds(req.admin, submitted);
const allowedSet = new Set(allowed.map(Number));
const illegalNew = submitted.filter((id) => !allowedSet.has(id) && !existingSet.has(id));
if (illegalNew.length) {
return res.status(403).json({ error: 'One or more events are not yours to assign' });
}
// setAssignmentsForCustomer replaces the FULL assignment list, deleting any
// existing row not in the submitted set. A restricted admin must not be able
// to revoke another admin's customer↔event links that way, so always retain
// the customer's existing assignments to events the caller does NOT own —
// regardless of whether the client echoed them back. super_admin owns
// everything, so nothing is force-preserved for them.
let finalEventIds = allowed.map(Number);
if (req.admin.roleName !== 'super_admin' && existingEventIds.length) {
const { allowed: ownedExisting } = await filterOwnedEventIds(req.admin, existingEventIds);
const ownedExistingSet = new Set(ownedExisting.map(Number));
const foreignExisting = existingEventIds.filter((id) => !ownedExistingSet.has(id));
finalEventIds = [...new Set([...finalEventIds, ...foreignExisting])];
}
const result = await customerAccountsService.setAssignmentsForCustomer(
parseInt(req.params.id, 10),
req.body.event_ids,
customerId,
finalEventIds,
req.admin.id,
);
successResponse(res, result);
+77 -28
View File
@@ -24,11 +24,47 @@ function normaliseDateKey(value) {
return String(value).slice(0, 10);
}
/**
* Event ids the caller's dashboard may aggregate over, or `null` when the
* caller is unrestricted (GHSA-c2jj / gqx7 / jhcf).
*
* These endpoints are gated only by `analytics.view`, which the `editor` role
* holds — yet the events *list* restricts editors to their own rows
* (adminEvents/crud.js: `roleName === 'editor'` → `created_by = admin.id`).
* The dashboard therefore reported instance-wide totals, and the analytics
* endpoint returned other admins' gallery names and slugs, to a role that
* cannot see those events anywhere else.
*
* Scoped on `editor` specifically to mirror the events list exactly, so the
* `admin` role's dashboard is unchanged. (`filterOwnedEventIds` uses the
* broader `!== super_admin` rule; the two conventions disagree in this
* codebase and matching the list is the no-regression choice.)
*
* @returns {Promise<number[]|null>} ids to restrict to, or null for no limit
*/
function isScopedAdmin(admin) {
return admin?.roleName === 'editor';
}
/**
* Restrict `query` to the caller's own events.
*
* Uses a SUBQUERY rather than materialising the id list. An editor owning more
* events than the driver's bind-parameter limit (~999 on SQLite, 65535 on
* Postgres) would otherwise blow past it once every id became a placeholder,
* turning all three dashboard endpoints into 500s — and even well below that
* limit the whole list was re-sent for each of the ~10 aggregates per request.
*/
function applyEventScope(query, admin, column) {
if (!isScopedAdmin(admin)) return query;
return query.whereIn(column, db('events').select('id').where('created_by', admin.id));
}
// Get dashboard statistics
router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
// Get active events count
const activeEvents = await db('events')
const activeEvents = await applyEventScope(db('events'), req.admin, 'id')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.count('id as count')
@@ -39,7 +75,7 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
const now = new Date();
const expiringEvents = await db('events')
const expiringEvents = await applyEventScope(db('events'), req.admin, 'id')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
@@ -48,12 +84,12 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
.first();
// Get total photos count
const totalPhotos = await db('photos')
const totalPhotos = await applyEventScope(db('photos'), req.admin, 'event_id')
.count('id as count')
.first();
// Get storage usage (sum of all photo sizes)
const storageUsed = await db('photos')
const storageUsed = await applyEventScope(db('photos'), req.admin, 'event_id')
.sum('size_bytes as total')
.first();
@@ -61,21 +97,21 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const totalViews = await db('access_logs')
const totalViews = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.where('action', 'view')
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
.count('id as count')
.first();
// Get total downloads (last 30 days) - include both single and bulk downloads
const totalDownloads = await db('access_logs')
.whereIn('action', ['download', 'download_all'])
const totalDownloads = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
.count('id as count')
.first();
// Get archived events count
const archivedEvents = await db('events')
const archivedEvents = await applyEventScope(db('events'), req.admin, 'id')
.where('is_archived', formatBoolean(true))
.count('id as count')
.first();
@@ -83,7 +119,7 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
// Get total events count (all events regardless of status) — used by the
// events list page to render accurate "All (N)" / Total Events counters
// when the table is server-paginated (#346).
const totalEvents = await db('events')
const totalEvents = await applyEventScope(db('events'), req.admin, 'id')
.count('id as count')
.first();
@@ -91,15 +127,15 @@ router.get('/stats', adminAuth, requirePermission('analytics.view'), async (req,
const sixtyDaysAgo = new Date();
sixtyDaysAgo.setDate(sixtyDaysAgo.getDate() - 60);
const previousViews = await db('access_logs')
const previousViews = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.where('action', 'view')
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
.where('timestamp', '<', thirtyDaysAgo.toISOString())
.count('id as count')
.first();
const previousDownloads = await db('access_logs')
.whereIn('action', ['download', 'download_all'])
const previousDownloads = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
.where('timestamp', '<', thirtyDaysAgo.toISOString())
.count('id as count')
@@ -136,9 +172,19 @@ router.get('/activity', adminAuth, requirePermission('analytics.view'), async (r
try {
const { limit } = getPagination(req, { limit: 10 });
const activities = await db('activity_logs')
.select('activity_logs.*', 'events.event_name')
.leftJoin('events', 'activity_logs.event_id', 'events.id')
// Scope the feed to the caller's own events (GHSA-jhcf) — it otherwise
// returned every admin's actions, including actor names and verbatim
// metadata. `activity_logs.event_id` is NULLABLE: system-level entries
// (logins, settings changes) carry no event, and those are deliberately
// EXCLUDED for a scoped caller rather than shown, since they are exactly
// the cross-admin actions this advisory is about.
const activities = await applyEventScope(
db('activity_logs')
.select('activity_logs.*', 'events.event_name')
.leftJoin('events', 'activity_logs.event_id', 'events.id'),
req.admin,
'activity_logs.event_id'
)
.orderBy('activity_logs.created_at', 'desc')
.limit(limit);
@@ -244,7 +290,7 @@ router.get('/health', adminAuth, requirePermission('settings.view'), async (req,
router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (req, res) => {
try {
const days = sanitizeDays(req.query.days || 7);
// Generate date range
const dates = [];
for (let i = days - 1; i >= 0; i--) {
@@ -262,21 +308,21 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
const startDateStr = startDate.toISOString();
// Get views per day
const viewsData = await db('access_logs')
const viewsData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
.where('action', 'view')
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
// Get downloads per day - include both single and bulk downloads
const downloadsData = await db('access_logs')
const downloadsData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
.whereIn('action', ['download', 'download_all'])
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
// Get unique visitors per day
const visitorsData = await db('access_logs')
const visitorsData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(DISTINCT ip_address) as count'))
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
@@ -303,11 +349,11 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
});
// Get top galleries by views with additional metrics
const topGalleries = await db('access_logs')
const topGalleries = await applyEventScope(db('access_logs'), req.admin, 'access_logs.event_id')
.select('events.id', 'events.event_name', 'events.slug')
.select(db.raw('COUNT(CASE WHEN action = \'view\' THEN 1 END) as views'))
.select(db.raw('COUNT(DISTINCT CASE WHEN action = \'view\' THEN ip_address END) as uniqueVisitors'))
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\') THEN 1 END) as downloads'))
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\', \'download_all_presigned\', \'download_selected\') THEN 1 END) as downloads'))
.join('events', 'access_logs.event_id', 'events.id')
.where('access_logs.timestamp', '>=', startDateStr)
.groupBy('events.id', 'events.event_name', 'events.slug')
@@ -324,7 +370,10 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
let devices = { desktop: 0, mobile: 0, tablet: 0 };
let devicesSource = 'access_logs';
const adapter = await resolveAdapter();
// The external tracker reports instance-wide device data with no way to
// filter it by event, so a scoped caller must not receive it (GHSA-gqx7).
// They fall through to the access_logs heuristic, which IS scoped.
const adapter = isScopedAdmin(req.admin) ? null : await resolveAdapter();
if (adapter) {
try {
const trackerDevices = await adapter.fetchDeviceBreakdown({
@@ -346,7 +395,7 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
// Local heuristic on access_logs user_agent. Coarse — `LIKE` doesn't
// cover every UA shape (some Android browsers, embedded webviews, etc.)
// — and counts come back as strings on Postgres, hence Number() below.
const deviceData = await db('access_logs')
const deviceData = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.select(
db.raw(`
CASE
@@ -370,19 +419,19 @@ router.get('/analytics', adminAuth, requirePermission('analytics.view'), async (
}
// Calculate totals for the period (matching /stats logic)
const totalViews = await db('access_logs')
const totalViews = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.where('action', 'view')
.where('timestamp', '>=', startDateStr)
.count('id as count')
.first();
const totalDownloadsCount = await db('access_logs')
.whereIn('action', ['download', 'download_all'])
const totalDownloadsCount = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.where('timestamp', '>=', startDateStr)
.count('id as count')
.first();
const totalUniqueVisitors = await db('access_logs')
const totalUniqueVisitors = await applyEventScope(db('access_logs'), req.admin, 'event_id')
.where('timestamp', '>=', startDateStr)
.countDistinct('ip_address as count')
.first();
+14 -2
View File
@@ -123,8 +123,20 @@ router.post('/backup', requirePermission('backup.create'), async (req, res) => {
trackingUrl: '/api/admin/database-backup/progress'
});
// Run backup in background
databaseBackupService.backup(req.body).catch(error => {
// Forward ONLY the real backup knobs (GHSA-jw8m). Passing req.body
// straight through let the caller set `destinationPath`, which the
// service merges over its config — so a backup.create holder (the
// `admin` role, which has neither settings.edit nor backup.restore)
// could dump the whole database into the PUBLIC /uploads static mount
// and fetch it unauthenticated, hashes and encrypted SMTP creds included.
// destinationPath is not a persistable setting; the request body was its
// only source, so dropping it here costs no legitimate behaviour.
const body = req.body || {};
const options = {};
for (const key of ['compress', 'validateIntegrity', 'includeChecksums']) {
if (body[key] !== undefined) options[key] = body[key];
}
databaseBackupService.backup(options).catch(error => {
logger.error('Manual database backup failed:', error);
});
} catch (error) {
+16 -14
View File
@@ -66,9 +66,11 @@ router.post('/config', [
tls_reject_unauthorized
} = req.body;
// Validate SMTP host is not a private/internal address (SSRF protection)
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(smtp_host)) {
// Validate SMTP host is not a private/internal address (SSRF protection).
// Resolves DNS so a public-looking hostname pointing at an internal IP
// is caught, not just literal private addresses (#GHSA-ch64).
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(smtp_host))) {
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
}
@@ -152,8 +154,8 @@ router.post('/incoming-config', [
const errors = validationResult(req);
if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() });
const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body;
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(imap_host)) {
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(imap_host))) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
const existing = await db('email_configs').first();
@@ -183,8 +185,8 @@ router.post('/incoming-config/folders', adminAuth, requirePermission('email.view
try {
const { imap_host, imap_port, imap_secure, imap_user, imap_pass } = req.body || {};
if (imap_host) {
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(imap_host)) {
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(imap_host))) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
}
@@ -205,8 +207,8 @@ router.post('/incoming-config/test', adminAuth, requirePermission('email.view'),
try {
const { imap_host, imap_port, imap_secure, imap_user, imap_pass, imap_folder } = req.body || {};
if (imap_host) {
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(imap_host)) {
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(imap_host))) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
}
@@ -385,11 +387,11 @@ router.post('/accounts', adminAuth, messagingGate, requirePermission('email.edit
if (!b.account_key) return res.status(400).json({ error: 'account_key is required' });
// SSRF guard — mirror /config + /incoming-config: neither the IMAP nor the
// SMTP host may point at a private/internal address.
const { isPrivateIP } = require('../utils/networkValidation');
if (b.imap_host && isPrivateIP(b.imap_host)) {
const { isHostAllowed } = require('../utils/networkValidation');
if (b.imap_host && !(await isHostAllowed(b.imap_host))) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
if (b.smtp_host && isPrivateIP(b.smtp_host)) {
if (b.smtp_host && !(await isHostAllowed(b.smtp_host))) {
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
}
const patch = {
@@ -434,8 +436,8 @@ router.post('/accounts', adminAuth, messagingGate, requirePermission('email.edit
router.post('/accounts/test', adminAuth, messagingGate, requirePermission('email.view'), async (req, res) => {
try {
const b = req.body || {};
const { isPrivateIP } = require('../utils/networkValidation');
if (b.imap_host && isPrivateIP(b.imap_host)) {
const { isHostAllowed } = require('../utils/networkValidation');
if (b.imap_host && !(await isHostAllowed(b.imap_host))) {
return res.status(400).json({ error: 'IMAP host cannot point to a private or internal network address' });
}
let pass = b.imap_pass;
+70 -9
View File
@@ -16,6 +16,7 @@ const path = require('path');
const { escapeLikePattern } = require('../../utils/sqlSecurity');
const { validatePasswordInContext, getBcryptRounds } = require('../../utils/passwordValidation');
const logger = require('../../utils/logger');
const { sanitizeForLog, sanitizeValidationErrors } = require('../../utils/sanitizeForLog');
const { errorResponse } = require('../../utils/routeHelpers');
const { buildShareLinkVariants } = require('../../services/shareLinkService');
const { parseBooleanInput } = require('../../utils/parsers');
@@ -126,10 +127,13 @@ module.exports = (router) => {
body('customer_account_ids.*').optional().isInt({ min: 1 })
], async (req, res) => {
try {
logger.debug('Create event request body', { body: req.body });
// Redact credentials — the body carries the gallery password (GHSA-r794).
logger.debug('Create event request body', { body: sanitizeForLog(req.body) });
const errors = validationResult(req);
if (!errors.isEmpty()) {
logger.error('Validation errors:', errors.array());
// errors.array() embeds the SUBMITTED value per field — including a
// rejected plaintext password (GHSA-r794).
logger.error('Validation errors:', sanitizeValidationErrors(errors.array()));
return res.status(400).json({ errors: errors.array() });
}
@@ -771,9 +775,11 @@ module.exports = (router) => {
.where('action', 'view')
.count('* as totalViews');
// One row per download event: singles AND zips (#895). Must stay in
// sync with adminDashboard's definition or the two surfaces disagree.
const [{ totalDownloads }] = await db('access_logs')
.where('event_id', id)
.where('action', 'download')
.whereIn('action', ['download', 'download_all', 'download_all_presigned', 'download_selected'])
.count('* as totalDownloads');
const [{ uniqueVisitors }] = await db('access_logs')
@@ -1261,12 +1267,59 @@ module.exports = (router) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
logger.debug('Update event validation errors', { errors: errors.array(), body: req.body });
// Redact credentials — an invalid update still logs the whole body (GHSA-pgmp).
logger.debug('Update event validation errors', { errors: sanitizeValidationErrors(errors.array()), body: sanitizeForLog(req.body) });
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const updates = { ...req.body };
// Strip identity/provenance/secret columns from the mass-assigned
// body (GHSA-3rqx). The handler spreads req.body straight into the
// events UPDATE, so without this an events.edit holder could rewrite
// ownership (created_by), routing identity (slug/share_link), the
// share/client tokens, or the password hashes directly. Plaintext
// `password`/`client_password` inputs are NOT stripped — those are the
// supported way to change credentials and get hashed below; the
// tokens are regenerated internally where needed.
// The handler spreads req.body straight into the events UPDATE, so any
// column an events.edit holder names is writable unless blocked here.
// This is a COMPLETE deny-set of every server-managed / permission-gated
// events column (enumerated from the schema); everything else is a
// legitimate edit-form field and passes through, including input-only
// keys (password/client_password) the handler transforms below. New
// server-managed columns MUST be added here. (codex review — GHSA-3rqx.)
const IMMUTABLE_EVENT_COLUMNS = [
// Identity / provenance
'id', 'created_by', 'created_at', 'updated_at', 'slug',
// Routing + share/client tokens (generated at create / internally)
'share_link', 'share_token', 'client_share_token', 'show_share_token',
// Secrets (set via the plaintext password/client_password inputs)
'password_hash', 'client_password_hash',
// Server-consumed file paths — e.g. DELETE /:id/logo fs.unlink()s
// hero_logo_path, so a forged value is an arbitrary-delete primitive.
'hero_logo_path', 'hero_logo_url', 'archive_path', 'download_zip_path',
// Server-managed timestamps
'download_zip_generated_at', 'archived_at', 'revealed_at', 'event_reminder_sent_at',
// Lifecycle — governed by dedicated permission-gated routes
// (events.archive/restore, publish, activate/deactivate), not events.edit.
'is_archived', 'is_draft', 'is_active',
// Relationships — managed by projectService.assignEvent + its
// customer-consistency checks, and events.edit ≠ quotes/contracts perms.
'project_id', 'quote_id',
// Legacy mirrors — rejected explicitly below in favour of customer_*.
'host_name', 'host_email',
];
// Case-insensitive match: SQLite treats quoted identifiers
// case-insensitively, so a `{ "Password_Hash": ... }` key would
// otherwise survive a case-sensitive delete and still hit the real
// column (codex review).
const denied = new Set(IMMUTABLE_EVENT_COLUMNS.map((c) => c.toLowerCase()));
for (const key of Object.keys(updates)) {
if (denied.has(key.toLowerCase())) delete updates[key];
}
const customerColumnsAvailable = await hasCustomerContactColumns();
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
@@ -1393,9 +1446,12 @@ module.exports = (router) => {
}
// Log the update request for debugging
// `updates` no longer holds the plaintext password (stripped above), but
// it still carries client_password_hash and — when regenerate_client_token
// was passed — a LIVE client_share_token bearer credential.
logger.debug('Update event request', {
id,
updates,
updates: sanitizeForLog(updates),
color_theme_length: updates.color_theme ? updates.color_theme.length : 0,
color_theme_type: typeof updates.color_theme,
hero_photo_id: updates.hero_photo_id,
@@ -1495,10 +1551,15 @@ module.exports = (router) => {
}
}
// Update event
await db('events')
.where('id', id)
.update(updates);
// Update event. Skip the write when the denylist (or masked secrets)
// left nothing to change — Knex rejects .update({}) with an error,
// which would surface as a 500 for an otherwise-valid no-op request
// (e.g. a body of only protected fields). (codex review.)
if (Object.keys(updates).length > 0) {
await db('events')
.where('id', id)
.update(updates);
}
// Customer-account assignments (#354). Same skip semantics as POST:
// ignore when the customer portal flag is off so stale tabs don't
+39 -8
View File
@@ -1,4 +1,5 @@
const express = require('express');
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
const router = express.Router();
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
@@ -164,6 +165,24 @@ router.get('/events/:eventId/feedback',
}
);
// Ownership guard for by-feedback-id routes (GHSA-2qc2 / GHSA-32h4). These
// take a :feedbackId (not :eventId), so requireEventOwnership can't apply —
// resolve the feedback's event and enforce the same rule (super_admin sees
// all; others need to own the event, or it's ownerless/legacy). Returns
// false and sends a 404 (not 403 — don't leak which feedback ids exist)
// when the caller may not act on it.
async function assertOwnsFeedback(req, res, feedbackId) {
if (req.admin.roleName === 'super_admin') return true;
const fb = await db('photo_feedback').where('id', feedbackId).first('event_id');
if (!fb) { res.status(404).json({ error: 'Feedback not found' }); return false; }
const event = await db('events').where('id', fb.event_id).first('created_by');
if (event && event.created_by && event.created_by !== req.admin.id) {
res.status(404).json({ error: 'Feedback not found' });
return false;
}
return true;
}
// Moderate feedback (approve/hide/reject)
router.put('/feedback/:feedbackId/:action',
adminAuth,
@@ -171,11 +190,12 @@ router.put('/feedback/:feedbackId/:action',
async (req, res) => {
try {
const { feedbackId, action } = req.params;
if (!['approve', 'hide', 'reject'].includes(action)) {
return res.status(400).json({ error: 'Invalid action' });
}
if (!(await assertOwnsFeedback(req, res, feedbackId))) return;
await feedbackService.moderateFeedback(feedbackId, action, req.admin.id);
res.json({ success: true });
@@ -193,7 +213,8 @@ router.delete('/feedback/:feedbackId',
async (req, res) => {
try {
const { feedbackId } = req.params;
if (!(await assertOwnsFeedback(req, res, feedbackId))) return;
await feedbackService.deleteFeedback(feedbackId, req.admin.id);
res.json({ success: true });
@@ -347,7 +368,15 @@ router.get('/feedback/pending-moderation',
requirePermission('events.view'),
async (req, res) => {
try {
const pending = await feedbackService.getPendingModeration();
// Scope to the caller's owned events unless super_admin (GHSA-3335).
let ownedEventIds = null;
if (req.admin.roleName !== 'super_admin') {
const rows = await db('events')
.where((q) => q.whereNull('created_by').orWhere('created_by', req.admin.id))
.select('id');
ownedEventIds = rows.map((r) => r.id);
}
const pending = await feedbackService.getPendingModeration(null, ownedEventIds);
res.json(pending);
} catch (error) {
logger.error('Error getting pending moderation:', error);
@@ -450,11 +479,13 @@ function convertToCSV(data) {
const value = row[header];
if (value === null || value === undefined) return '';
if (typeof value === 'boolean') return value ? 'yes' : 'no';
if (typeof value === 'string'
&& (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r'))) {
return `"${value.replace(/"/g, '""')}"`;
// Formula-neutralize user-controlled cells (guest_name/comment_text)
// before quoting — quoting alone doesn't stop `=cmd()` (GHSA-3cw3).
const neutralized = neutralizeSpreadsheetFormula(value);
if (neutralized.includes(',') || neutralized.includes('"') || neutralized.includes('\n') || neutralized.includes('\r')) {
return `"${neutralized.replace(/"/g, '""')}"`;
}
return value;
return neutralized;
}).join(',');
});
+6 -1
View File
@@ -39,8 +39,13 @@ function serializeGuest(row) {
};
}
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
function escapeCsvCell(value) {
const str = value == null ? '' : String(value);
// Neutralize spreadsheet formulas FIRST (a `=cmd()` guest name executes on
// open — RFC-4180 quoting doesn't stop it), then quote-wrap (GHSA-wc99 /
// GHSA-f4fp).
const str = neutralizeSpreadsheetFormula(value);
if (/[,"\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`;
}
+11 -2
View File
@@ -502,6 +502,15 @@ router.get('/export', adminAuth, requirePermission('settings.view'), async (req,
/**
* Helper function to convert data to CSV
*/
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
function csvCell(value) {
// Formula-neutralize, then RFC-4180 quote (the previous join('') did
// neither — GHSA-37p4).
const s = neutralizeSpreadsheetFormula(value);
return /[,"\n\r]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
}
function convertToCSV(data) {
// Simplified CSV conversion for security logs
const headers = ['timestamp', 'event_type', 'client_ip', 'details'];
@@ -511,8 +520,8 @@ function convertToCSV(data) {
log.client_ip,
JSON.stringify(log.details || {})
]);
return [headers.join(','), ...rows.map(row => row.join(','))].join('\n');
return [headers.join(','), ...rows.map(row => row.map(csvCell).join(','))].join('\n');
}
module.exports = router;
+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);
})
);
+88 -5
View File
@@ -315,9 +315,10 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
const crypto = require('crypto');
const uploadId = crypto.randomBytes(16).toString('hex');
// Counter base — same approximation as before. Strict uniqueness is
// already enforced by the filename template + DB unique index, so a
// small race here just retries a counter on conflict (rare).
// Counter base — a per-request approximation (concurrent upload
// requests can compute the same base; there is NO unique index on
// photos.filename). Uniqueness of the final path comes from the
// random suffix inside generatePhotoFilename (#931).
const existingCount = await db('photos')
.where({ event_id: eventId, type: photoType })
.count('id as count')
@@ -756,6 +757,16 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
.where({ id: photoId, event_id: eventId })
.update(updateData);
// A visibility or category change alters which photos belong in the
// guest download bundle — drop the cached ZIP so it rebuilds fresh,
// otherwise a hide→unhide cycle can leave the stale cache omitting
// photos added in between (codex review).
if (updateData.visibility !== undefined
|| Object.prototype.hasOwnProperty.call(updateData, 'category_id')
|| Object.prototype.hasOwnProperty.call(updateData, 'type')) {
downloadZipService.invalidate(parseInt(eventId, 10));
}
// Fetch and return the updated photo
const updatedPhoto = await db('photos')
.where({ id: photoId, event_id: eventId })
@@ -904,6 +915,14 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
.where('event_id', eventId)
.update(updateData);
// Visibility/category changes alter the guest download bundle — drop the
// cached ZIP so it rebuilds fresh (codex review).
if (updateData.visibility !== undefined
|| Object.prototype.hasOwnProperty.call(updateData, 'category_id')
|| Object.prototype.hasOwnProperty.call(updateData, 'type')) {
downloadZipService.invalidate(parseInt(eventId, 10));
}
res.json({ message: `${photoIds.length} photos updated successfully` });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update photos');
@@ -1091,7 +1110,13 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requ
average_rating: photo.average_rating || 0,
comment_count: commentMap[photo.id] || 0,
like_count: photo.like_count || 0,
favorite_count: photo.favorite_count || 0
favorite_count: photo.favorite_count || 0,
// Engagement counters (#895 follow-up): the grid reads these, but
// this explicit mapper never included them — so the Engagement
// column showed 0 regardless of what the DB counted. This, not
// stale data, was why per-image downloads always displayed 0.
view_count: photo.view_count || 0,
download_count: photo.download_count || 0
}))
});
} catch (error) {
@@ -1126,7 +1151,65 @@ router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view
const event = await db('events').where('id', eventId).first();
const storageKey = resolvePhotoStorageKey(event, photo);
res.setHeader('Content-Type', `image/${path.extname(photo.filename).slice(1)}`);
// Content-Type resolution (#908 + external review). Invariant: the
// header is ALWAYS image/* or video/*.
// - photos.mime_type is never echoed verbatim unless it is a video/
// type: the chunked-upload path stores the client-sent MIME
// unvalidated, so a stored text/html served inline under the app
// origin would be a same-origin XSS gift.
// - Images ignore the stored value entirely — migration 039
// backfilled image/jpeg onto every legacy row (PNGs included), so
// the extension is the more trustworthy signal; normalized via the
// shared map (image/jpg → image/jpeg), jpeg fallback when unknown.
// - Videos prefer a stored video/ type, then the extension map
// (.mov → video/quicktime, .webm → video/webm, …), then video/mp4.
// The old ext-derived image/<ext> (image/mp4) is what made the
// admin player's blob unplayable (#908).
const { EXTENSION_TO_MIME } = require('../services/uploadSettings');
const ext = path.extname(photo.filename).slice(1).toLowerCase();
// Own-property lookup (review): a client-controlled filename ending in
// .constructor / .__proto__ / .toString would otherwise return an
// inherited Object.prototype member, and the extMime.startsWith below
// would throw — a permanent 500 for that photo instead of the fallback.
const extMime = Object.prototype.hasOwnProperty.call(EXTENSION_TO_MIME, ext)
? EXTENSION_TO_MIME[ext]
: null;
// Full-token validation, not just a prefix check: the stored value is
// client-controlled, and header-invalid characters (video/mp4\r\nX: y)
// would make setHeader throw — a permanent 500 for that photo. Bare
// 'video/' is equally invalid; both fall back to the extension map.
const storedVideoMime = photo.mime_type && /^video\/[\w.+-]+$/.test(photo.mime_type)
? photo.mime_type
: null;
// Honor a stored image MIME for any header-safe RASTER type (#908
// review): the S3 auto-importer accepts arbitrary image/* from
// mime-types and stores it (avif/bmp/tiff/heic/apng/ico/jxl/…), and a
// hand-listed allowlist kept missing formats. Allow image/<token> but
// NEVER the scriptable svg / *+xml family (image/svg+xml executes
// inline). The strict token + anchors also block header injection
// (image/x\r\nY:). Migration 039's blanket image/jpeg backfill on
// legacy rows is why the mapped extension still wins ahead of this.
const storedImageMime =
photo.mime_type &&
/^image\/[\w.+-]+$/.test(photo.mime_type) &&
!/^image\/svg|xml/i.test(photo.mime_type)
? photo.mime_type
: null;
const isVideo = photo.media_type === 'video' ||
Boolean(storedVideoMime) ||
Boolean(extMime && extMime.startsWith('video/'));
// Never interpolate the raw extension on the image side: it would
// synthesize image/svg+xml (scriptable inline) or header-invalid values
// from client-controlled chunked-upload filenames. Precedence is
// mapped-extension (also corrects the 039 legacy-jpeg backfill on PNGs)
// -> safe stored raster MIME (auto-imported avif/bmp/tiff) -> image/jpeg.
// A stored type outside the allowlist degrades to image/jpeg; browsers
// sniff image bytes in <img>/blob contexts, so a mislabel is harmless
// where an injected type is not.
const contentType = isVideo
? storedVideoMime || (extMime && extMime.startsWith('video/') ? extMime : null) || 'video/mp4'
: (extMime && extMime.startsWith('image/') ? extMime : null) || storedImageMime || 'image/jpeg';
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'private, max-age=3600');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
+92 -14
View File
@@ -15,8 +15,32 @@ const { requirePermission, userHasAnyPermission } = require('../middleware/permi
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const projectService = require('../services/projectService');
const { db } = require('../database/db');
const { ownedProjectsSubquery, requireProjectOwnership, filterOwnedEventIds } = require('../middleware/ownership');
const { ForbiddenError } = require('../utils/errors');
const router = express.Router();
// A deal that spans both quotes and contracts cascades a project link across
// BOTH tables (projectService.linkDealToProject). So attaching one document
// must also require manage permission on the OTHER domain the cascade will
// touch — otherwise quotes.manage alone could re-point a linked contract, and
// vice versa (GHSA-v4vw / codex review). No-op when the deal touches only the
// one domain, or on older instances without the deal_uuid column.
async function assertCascadePermitted(req, docTable, docId, otherTable, otherPerm) {
let doc;
try {
doc = await db(docTable).where({ id: docId }).first('deal_uuid');
} catch { return; }
if (!doc || !doc.deal_uuid) return;
let linked;
try {
linked = await db(otherTable).where({ deal_uuid: doc.deal_uuid }).first('id');
} catch { return; }
if (!linked) return;
if (!(await userHasAnyPermission(req.admin.id, [otherPerm]))) {
throw new ForbiddenError(`This deal also links a ${otherTable.replace(/s$/, '')}; the ${otherPerm} permission is required`);
}
}
router.use(adminAuth);
// Projects is feature-flagged like bills/quotes — when off, the whole cockpit
@@ -42,10 +66,15 @@ router.get('/', requirePermission('events.view'), handleAsync(async (req, res) =
bills: await userHasAnyPermission(req.admin.id, ['bills.view']),
quotes: await userHasAnyPermission(req.admin.id, ['quotes.view']),
};
// Only the caller's projects (GHSA-wrg5). Passed as a SUBQUERY so a large
// project count can't hit the driver's bind-parameter limit; null means
// unrestricted.
const projectIds = ownedProjectsSubquery(req.admin);
const projects = await projectService.listProjects({
search: req.query.q || '',
status: req.query.status || null,
perms,
projectIds,
});
return successResponse(res, { projects });
}));
@@ -65,7 +94,7 @@ router.post('/',
);
// Detail
router.get('/:id', requirePermission('events.view'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
router.get('/:id', requirePermission('events.view'), requireProjectOwnership, [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
validateRequest(req);
const project = await projectService.getProjectById(parseInt(req.params.id, 10));
if (!project) return res.status(404).json({ error: 'Project not found' });
@@ -75,6 +104,7 @@ router.get('/:id', requirePermission('events.view'), [param('id').isInt({ min: 1
// Update
router.put('/:id',
requirePermission('events.edit'),
requireProjectOwnership,
[
param('id').isInt({ min: 1 }),
body('name').optional().isString().trim().isLength({ min: 1, max: 255 }),
@@ -96,50 +126,98 @@ router.put('/:id',
// Attach an event to the project
router.post('/:id/events',
requirePermission('events.edit'),
requireProjectOwnership,
[param('id').isInt({ min: 1 }), body('eventId').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await projectService.assignEvent(parseInt(req.params.id, 10), parseInt(req.body.eventId, 10));
const eventId = parseInt(req.body.eventId, 10);
// Both sides must be the caller's (GHSA-wrg5): requireProjectOwnership
// covers the project, this covers the INCOMING event. Otherwise an editor
// could pull a foreign event into a project they own and then read that
// event's rolled-up documents through /:id/overview.
const { denied } = await filterOwnedEventIds(req.admin, [eventId]);
if (denied.length) {
return res.status(403).json({ error: 'That event is not yours to attach' });
}
const result = await projectService.assignEvent(parseInt(req.params.id, 10), eventId);
return successResponse(res, result, 200, 'Event attached to project');
}),
);
// Attach a quote to the project (quotes carry no event_id — migration 121).
// Requires quotes.manage in addition to events.edit — attaching a quote
// mutates a separately-permissioned document domain (GHSA-v4vw).
router.post('/:id/quotes',
requirePermission('events.edit'),
requirePermission(['events.edit', 'quotes.manage'], { requireAll: true }),
requireProjectOwnership,
[param('id').isInt({ min: 1 }), body('quoteId').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await projectService.assignQuote(parseInt(req.params.id, 10), parseInt(req.body.quoteId, 10));
const quoteId = parseInt(req.body.quoteId, 10);
await assertCascadePermitted(req, 'quotes', quoteId, 'contracts', 'contracts.manage');
const result = await projectService.assignQuote(parseInt(req.params.id, 10), quoteId, req.admin);
return successResponse(res, result, 200, 'Quote attached to project');
}),
);
// Attach a contract to the project.
// Attach a contract to the project. Requires contracts.manage in addition
// to events.edit (GHSA-v4vw).
router.post('/:id/contracts',
requirePermission('events.edit'),
requirePermission(['events.edit', 'contracts.manage'], { requireAll: true }),
requireProjectOwnership,
[param('id').isInt({ min: 1 }), body('contractId').isInt({ min: 1 })],
handleAsync(async (req, res) => {
validateRequest(req);
const result = await projectService.assignContract(parseInt(req.params.id, 10), parseInt(req.body.contractId, 10));
const contractId = parseInt(req.body.contractId, 10);
await assertCascadePermitted(req, 'contracts', contractId, 'quotes', 'quotes.manage');
const result = await projectService.assignContract(parseInt(req.params.id, 10), contractId, req.admin);
return successResponse(res, result, 200, 'Contract attached to project');
}),
);
// The cockpit aggregation — doc types gated on the admin's own permissions
router.get('/:id/overview', requirePermission('events.view'), [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
router.get('/:id/overview', requirePermission('events.view'), requireProjectOwnership, [param('id').isInt({ min: 1 })], handleAsync(async (req, res) => {
validateRequest(req);
const perms = {
bills: await userHasAnyPermission(req.admin.id, ['bills.view']),
quotes: await userHasAnyPermission(req.admin.id, ['quotes.view']),
contracts: await userHasAnyPermission(req.admin.id, ['contracts.view']),
};
const overview = await projectService.getProjectOverview(parseInt(req.params.id, 10), perms);
const overview = await projectService.getProjectOverview(parseInt(req.params.id, 10), perms, req.admin);
return successResponse(res, overview);
}));
/**
* These routes key on an `email_queue` id alone (GHSA-93x4) — nothing tied the
* row to a project or event the caller can see, so any admin holding
* `events.view` / `email.send` could preview, resend, cancel or retry ANY
* queued mail on the instance by walking ids.
*
* Scoped via `email_queue.event_id` → the caller's owned events. `event_id` is
* NULL for CRM document mail (quote/contract/invoice sends carry no event), and
* those rows have no ownable parent here, so a scoped caller is denied them
* rather than guessed into access. 404, not 403, so this isn't an id oracle.
*/
async function requireOwnedQueuedEmail(req, res, next) {
try {
if (req.admin?.roleName === 'super_admin') return next();
const emailId = parseInt(req.params.emailId, 10);
const row = await db('email_queue').where({ id: emailId }).first('event_id');
if (!row || !row.event_id) {
return res.status(404).json({ error: 'Email not found' });
}
const { denied } = await filterOwnedEventIds(req.admin, [row.event_id]);
if (denied.length) {
return res.status(404).json({ error: 'Email not found' });
}
return next();
} catch (err) {
return next(err);
}
}
// Email preview — the ACTUAL sent HTML (or null for pre-rendered_html rows)
router.get('/email/:emailId/preview', requirePermission('events.view'), [param('emailId').isInt({ min: 1 })], handleAsync(async (req, res) => {
router.get('/email/:emailId/preview', requirePermission('events.view'), [param('emailId').isInt({ min: 1 })], requireOwnedQueuedEmail, handleAsync(async (req, res) => {
validateRequest(req);
const preview = await projectService.getEmailPreview(parseInt(req.params.emailId, 10));
return successResponse(res, preview);
@@ -152,9 +230,9 @@ const emailAction = (fn) => handleAsync(async (req, res) => {
const result = await projectService[fn](parseInt(req.params.emailId, 10), req.admin.id);
return successResponse(res, result);
});
router.post('/email/:emailId/resend', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('resendEmail'));
router.post('/email/:emailId/cancel', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('cancelEmail'));
router.post('/email/:emailId/retry', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('retryEmail'));
router.post('/email/:emailId/send-now', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], emailAction('sendEmailNow'));
router.post('/email/:emailId/resend', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], requireOwnedQueuedEmail, emailAction('resendEmail'));
router.post('/email/:emailId/cancel', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], requireOwnedQueuedEmail, emailAction('cancelEmail'));
router.post('/email/:emailId/retry', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], requireOwnedQueuedEmail, emailAction('retryEmail'));
router.post('/email/:emailId/send-now', requirePermission('email.send'), [param('emailId').isInt({ min: 1 })], requireOwnedQueuedEmail, emailAction('sendEmailNow'));
module.exports = router;
+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);
})
);
+75
View File
@@ -91,6 +91,12 @@ router.post('/validate', requirePermission('backup.restore'), [
}
try {
// Constrain the caller-supplied paths to configured backup roots (GHSA-fw4c)
const pathError = await checkRestorePathsAllowed(req.body);
if (pathError) {
return res.status(400).json({ success: false, error: pathError });
}
// Transform S3 config from frontend format
const s3Config = transformS3Config(req.body);
@@ -165,6 +171,12 @@ router.post('/start', requirePermission('backup.restore'), [
});
}
// Constrain the caller-supplied paths to configured backup roots (GHSA-fw4c)
const pathError = await checkRestorePathsAllowed(req.body);
if (pathError) {
return res.status(400).json({ success: false, error: pathError });
}
// Check permissions for dangerous options
const settings = await getRestoreSettings();
if (req.body.force && !settings.restore_allow_force) {
@@ -759,4 +771,67 @@ async function getBackupConfig() {
return config;
}
/**
* GHSA-fw4c: `source` and `manifestPath` were validated only as "not empty"
* before being handed to the privileged restore engine, which reads them,
* parses the manifest and executes the referenced SQL against the live
* database. Constrain them to the operator-configured backup locations.
*
* The allowlist is the SAME set the restore wizard discovers from
* (`backup_destination_path` + `backup_manifest_path`), so the disaster-
* recovery flow is untouched: an operator restoring from a rescued mount
* already has to point those settings at it for the backup to be listed.
* RESTORE_ALLOWED_ROOTS (colon-separated) is an escape hatch for unusual
* layouts. S3 sources are URLs, not paths, and are validated elsewhere.
*
* @returns {Promise<string|null>} an error message, or null when acceptable
*/
// `source` is usually a SOURCE TYPE, not a path: the restore wizard posts
// 'local' | 's3' | 'upload' and restoreService.restore() branches on those
// literals before deriving an actual directory (see its comment at the
// `options.source === 'local'` branch). Treating them as paths resolved
// 'local' to <cwd>/local, failed containment, and 400'd the entire normal
// restore workflow — so type tokens are excluded from the path check.
const SOURCE_TYPE_TOKENS = ['local', 's3', 'upload'];
async function checkRestorePathsAllowed({ source, manifestPath }) {
const isS3 = (v) => typeof v === 'string' && v.startsWith('s3://');
const isTypeToken = (v) => typeof v === 'string'
&& SOURCE_TYPE_TOKENS.includes(v.trim().toLowerCase());
const candidates = [source, manifestPath]
.filter((v) => v && !isS3(v) && !isTypeToken(v));
if (candidates.length === 0) return null;
const config = await getBackupConfig();
const roots = [];
if (config.backup_destination_path) roots.push(config.backup_destination_path);
if (config.backup_manifest_path) roots.push(config.backup_manifest_path);
for (const extra of (process.env.RESTORE_ALLOWED_ROOTS || '').split(':')) {
if (extra.trim()) roots.push(extra.trim());
}
if (roots.length === 0) {
// Nothing configured to compare against — a restore can't be scoped, so
// don't pretend to enforce. Discovery would find nothing either.
return null;
}
const resolvedRoots = roots.map((r) => path.resolve(r));
for (const candidate of candidates) {
const resolved = path.resolve(candidate);
const inside = resolvedRoots.some(
(root) => resolved === root || resolved.startsWith(root + path.sep)
);
if (!inside) {
logger.warn('Refusing restore path outside the configured backup roots', {
candidate, roots,
});
return 'Backup source and manifest path must be inside a configured backup location';
}
}
return null;
}
module.exports = router;
// Exposed for tests: the source/manifestPath containment rules (GHSA-fw4c) are
// worth pinning directly, especially the source-TYPE-token carve-out.
module.exports._internal = { checkRestorePathsAllowed, SOURCE_TYPE_TOKENS };
+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);
+5 -5
View File
@@ -20,7 +20,7 @@ const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { validateExternalUrl } = require('../utils/networkValidation');
const { validateExternalUrlAsync } = require('../utils/networkValidation');
const webhookService = require('../services/webhookService');
const logger = require('../utils/logger');
@@ -78,9 +78,9 @@ router.post(
requirePermission('settings.edit'),
[
body('name').isString().trim().isLength({ min: 1, max: 100 }),
body('url').isString().isLength({ max: 2048 }).custom((url) => {
body('url').isString().isLength({ max: 2048 }).custom(async (url) => {
if (ALLOW_PRIVATE_URLS) return true;
const check = validateExternalUrl(url);
const check = await validateExternalUrlAsync(url);
if (!check.valid) throw new Error(check.error);
return true;
}),
@@ -160,9 +160,9 @@ router.put(
requirePermission('settings.edit'),
[
body('name').optional().isString().trim().isLength({ min: 1, max: 100 }),
body('url').optional().isString().isLength({ max: 2048 }).custom((url) => {
body('url').optional().isString().isLength({ max: 2048 }).custom(async (url) => {
if (ALLOW_PRIVATE_URLS) return true;
const check = validateExternalUrl(url);
const check = await validateExternalUrlAsync(url);
if (!check.valid) throw new Error(check.error);
return true;
}),
+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');
+206 -62
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');
@@ -30,6 +35,7 @@ const { handleAsync, errorResponse } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
const downloadZipService = require('../services/downloadZipService');
const { applyPhotoVisibilityFilter, canSeeHiddenPhotos } = require('../utils/photoVisibility');
const {
getUseOriginalFilenames,
pickRawDownloadName,
@@ -83,9 +89,20 @@ router.get('/resolve/:identifier', handleAsync(async (req, res) => {
}
const { event, matchType, shareToken } = result;
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
// The share_token is a bearer secret. Only return it (and the share
// links/URLs that embed it) when the caller already proved they hold it —
// i.e. they resolved via the token or the full share link. A bare *slug*
// lookup (slugs appear in gallery URLs and are guessable) must NOT hand
// back the secret, or an anonymous caller could turn a known slug into
// share-link access to a no-password gallery (GHSA-rh8r).
const callerHasToken = matchType !== 'slug';
if (!callerHasToken) {
return res.json({ slug: event.slug, matchType, requires_password: requiresPassword });
}
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
res.json({
slug: event.slug,
token: shareToken,
@@ -522,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')
@@ -587,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)
}));
}
@@ -614,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
@@ -663,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',
@@ -714,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,
@@ -722,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,
@@ -784,6 +814,10 @@ router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (re
.where({ id: photoId, event_id: req.event.id })
.update({ visibility });
// A client hiding/showing a photo changes the guest download bundle —
// drop the cached ZIP so it rebuilds fresh (codex review).
downloadZipService.invalidate(req.event.id);
res.json({ message: 'Photo visibility updated', visibility });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update photo visibility');
@@ -812,6 +846,10 @@ router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, r
.where('event_id', req.event.id)
.update({ visibility });
// Client bulk hide/show alters the guest download bundle — invalidate
// the cached ZIP (codex review).
downloadZipService.invalidate(req.event.id);
res.json({ message: `${count} photos updated`, visibility });
} catch (error) {
errorResponse(res, error, 500, 'Failed to update photo visibility');
@@ -824,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' });
}
@@ -848,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' });
}
}
@@ -933,15 +971,44 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, denySlideshowToken,
});
// Download all photos as ZIP
// Zip downloads count toward each contained photo's download_count (#895)
// — previously only single-photo downloads did, so galleries whose guests
// grab the zip showed 0 per-photo downloads forever. Used by the
// pre-generated-zip branches only: it mirrors downloadZipService._build,
// which zips EVERY event photo with no per-category allow_downloads
// filter — the counter has to reflect what actually shipped. (That the
// prebuilt zip ignores per-category download opt-outs is a separate,
// pre-existing issue.) Known approximation: _build skips entries whose
// WATERMARK step fails and still publishes the zip; counting those
// would need a persisted archive manifest, which isn't worth it for
// that tail case. Fire-and-forget at the call sites: counters must
// never fail a download.
async function bumpEventDownloadCounts(eventId) {
await db('photos').where('event_id', eventId).increment('download_count', 1);
}
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' });
}
// Try to serve pre-generated zip (instant download with Content-Length)
const zipInfo = await downloadZipService.getZipInfo(req.event.id);
// Try to serve pre-generated zip (instant download with Content-Length).
// Guests may use the prebuilt cache ONLY when the event has no hidden
// photos: a cache built before a photo was hidden — or before this
// visibility-aware builder shipped — could otherwise still leak it, and
// getZipInfo only checks the DB pointer + file stat, not freshness. When
// hidden photos exist, guests fall through to the visibility-filtered
// stream below. PIN-clients always stream a full archive.
const isClient = canSeeHiddenPhotos(req.accessLevel);
const eventHasHidden = await db('photos')
.where({ event_id: req.event.id, visibility: 'hidden' })
.first()
.then(Boolean);
const zipInfo = (isClient || eventHasHidden)
? null
: await downloadZipService.getZipInfo(req.event.id);
if (zipInfo) {
const storage = getStorage();
@@ -962,6 +1029,7 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
user_agent: req.headers['user-agent'],
action: 'download_all_presigned'
}).catch(() => {});
bumpEventDownloadCounts(req.event.id).catch(() => {});
res.redirect(302, url);
return;
} catch (err) {
@@ -985,26 +1053,35 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
user_agent: req.headers['user-agent'],
action: 'download_all'
}).catch(() => {});
bumpEventDownloadCounts(req.event.id).catch(() => {});
return;
}
// Fallback: on-the-fly streaming (existing behavior)
// Also trigger background zip generation for next time
downloadZipService.generateZip(req.event.id).catch(err =>
logger.warn('Background zip generation failed', { eventId: req.event.id, error: err.message })
);
// Fallback: on-the-fly streaming (existing behavior). Only pre-build the
// guest cache when it will actually be served next time — a guest
// download of an event with no hidden photos. Client bypasses and
// hidden-photo events always stream, so rebuilding the guest archive on
// those requests is wasted I/O (codex review).
if (!isClient && !eventHasHidden) {
downloadZipService.generateZip(req.event.id).catch(err =>
logger.warn('Background zip generation failed', { eventId: req.event.id, error: err.message })
);
}
// Fetch photos — exclude photos in categories that disabled downloads (#640).
// Uncategorised photos are always included; categories without the column
// (pre-migration-135) fall through the LEFT JOIN's null and are included.
const photos = await db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.where(function () {
this.whereNull('photos.category_id')
.orWhere('photo_categories.allow_downloads', true)
.orWhereNull('photo_categories.allow_downloads');
})
const photos = await applyPhotoVisibilityFilter(
db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.where(function () {
this.whereNull('photos.category_id')
.orWhere('photo_categories.allow_downloads', true)
.orWhereNull('photo_categories.allow_downloads');
}),
req.accessLevel
)
.select('photos.*')
.orderBy('photos.type', 'asc')
.orderBy('photos.uploaded_at', 'desc');
@@ -1044,6 +1121,10 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
// get a deterministic `_1` suffix before the entries hit the archive.
const useOriginalBulk = await getUseOriginalFilenames();
const bulkEntryNames = getZipEntryNames(photos, useOriginalBulk);
// Only photos whose append succeeded count as downloaded (#895) — the
// catch below deliberately skips missing/corrupt sources, and those
// never make it into the archive.
const appendedIds = [];
for (let i = 0; i < photos.length; i += 1) {
const photo = photos[i];
const storageKey = resolvePhotoStorageKey(req.event, photo);
@@ -1057,6 +1138,22 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
}
try {
// Verify the source exists BEFORE appending — but only for local
// sources: fs.createReadStream is lazy, so its error fires outside
// this try/catch and the archive 'error' handler then kills the
// whole response instead of skipping one photo (#895 review). S3's
// get() awaits GetObject and rejects right here on a missing key,
// so a preflight HEAD per entry would just be a redundant serial
// round trip (500-photo zip = 500 extra HEADs).
if (storageKey && storage.kind() === 'local') {
const srcStat = await storage.stat(storageKey);
if (!srcStat) {
throw new Error(`Photo missing in storage: ${storageKey}`);
}
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
throw new Error('Photo file missing on disk');
}
if (shouldApplyWatermark && effectiveSettings) {
// Watermark service operates on a local path. For managed photos in
// S3 mode, materialize a tmp local copy first.
@@ -1076,9 +1173,9 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
const stream = await storage.get(storageKey);
archive.append(stream, { name: archiveName });
} else {
const filePath = resolvePhotoFilePath(req.event, photo);
archive.file(filePath, { name: archiveName });
archive.file(resolvePhotoFilePath(req.event, photo), { name: archiveName });
}
appendedIds.push(photo.id);
} catch (err) {
logger.warn('Skipping photo in bulk download due to error', {
slug: req.params.slug,
@@ -1098,6 +1195,12 @@ router.get('/:slug/download-all', verifyGalleryAccess, denySlideshowToken, async
user_agent: req.headers['user-agent'],
action: 'download_all'
});
// Exactly the photos that made it into this archive (#895) — skipped
// (missing/corrupt) sources don't count.
if (appendedIds.length > 0) {
db('photos').whereIn('id', appendedIds)
.increment('download_count', 1).catch(() => {});
}
} catch (error) {
errorResponse(res, error, 500, 'Failed to create download archive');
}
@@ -1107,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' });
}
@@ -1128,15 +1231,18 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
// Fetch photos — exclude photos in categories that disabled downloads (#640).
// Same LEFT JOIN pattern as the download-all endpoint.
const photos = await db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.whereIn('photos.id', photoIds)
.where(function () {
this.whereNull('photos.category_id')
.orWhere('photo_categories.allow_downloads', true)
.orWhereNull('photo_categories.allow_downloads');
})
const photos = await applyPhotoVisibilityFilter(
db('photos')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', req.event.id)
.whereIn('photos.id', photoIds)
.where(function () {
this.whereNull('photos.category_id')
.orWhere('photo_categories.allow_downloads', true)
.orWhereNull('photo_categories.allow_downloads');
}),
req.accessLevel
)
.select('photos.*')
.orderBy('photos.uploaded_at', 'desc');
@@ -1179,11 +1285,26 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
// #493: same display-name resolution as bulk download, with dedup.
const useOriginalSelected = await getUseOriginalFilenames();
const selectedEntryNames = getZipEntryNames(photos, useOriginalSelected);
// Only photos whose append succeeded count as downloaded (#895).
const appendedIds = [];
for (let i = 0; i < photos.length; i += 1) {
const photo = photos[i];
const name = selectedEntryNames[i] || `photo-${photo.id}.jpg`;
const storageKey = resolveSelectedKey(req.event, photo);
try {
// Same pre-append source check as download-all (#895 review),
// local backend only: a lazy fs stream's async error would kill
// the response instead of skipping the photo; S3's get() rejects
// at the await below, so no redundant per-entry HEAD there.
if (storageKey && selectedStorage.kind() === 'local') {
const srcStat = await selectedStorage.stat(storageKey);
if (!srcStat) {
throw new Error(`Photo missing in storage: ${storageKey}`);
}
} else if (!storageKey && !fs.existsSync(resolvePhotoFilePath(req.event, photo))) {
throw new Error('Photo file missing on disk');
}
if (shouldApplyWatermark && effectiveSettings) {
const buf = storageKey
? await withSelectedLocalCopy(storageKey, (lp) =>
@@ -1197,6 +1318,7 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
} else {
archive.file(resolvePhotoFilePath(req.event, photo), { name });
}
appendedIds.push(photo.id);
} catch (err) {
logger.warn('Skipping selected photo due to error', {
slug: req.params.slug,
@@ -1215,12 +1337,49 @@ router.post('/:slug/download-selected', verifyGalleryAccess, denySlideshowToken,
user_agent: req.headers['user-agent'],
action: 'download_selected'
});
// Exactly the photos that made it into this archive (#895) — skipped
// (missing/corrupt) sources don't count.
if (appendedIds.length > 0) {
db('photos').whereIn('id', appendedIds)
.increment('download_count', 1).catch(() => {});
}
} catch (error) {
errorResponse(res, error, 500, 'Failed to download selected photos');
}
});
// Explicit per-photo view beacon (#895). Counting views on the image-
// serving routes is wrong in both directions: the lightbox preloads the
// prev/next neighbours (three fetches per open), while a preloaded
// neighbour that becomes the current slide is never re-fetched (#505
// keeps the DOM node alive across the swipe) — so request-level counters
// overcount preloads AND undercount swipe-throughs. Instead the lightbox
// pings this endpoint exactly when a photo becomes the visible slide.
// This also covers enhanced/maximum-protection galleries, whose bytes
// are served by /api/secure-images and never pass the routes below.
// The slideshow kiosk is excluded (denySlideshowToken; migration 138).
router.post('/:slug/photo/:photoId/view',
verifyGalleryAccess,
denySlideshowToken,
async (req, res) => {
try {
const photo = await db('photos')
.where({ id: req.params.photoId, event_id: req.event.id })
.first('id', 'visibility');
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
await db('photos').where('id', photo.id).increment('view_count', 1);
res.status(204).end();
} catch (error) {
errorResponse(res, error, 500, 'Failed to record view');
}
});
// View single photo (with watermark if enabled)
router.get('/:slug/photo/:photoId',
verifyGalleryAccess,
@@ -1731,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) => {
+53 -14
View File
@@ -7,6 +7,7 @@ const secureImageService = require('../services/secureImageService');
const { getStorage } = require('../services/storage');
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('../services/photoResolver');
const { withLocalCopy } = require('../services/imageProcessor');
const { isPhotoHiddenFromViewer, canSeeHiddenPhotos } = require('../utils/photoVisibility');
const crypto = require('crypto');
const logger = require('../utils/logger');
const { timingSafeEqualStr } = require('../utils/timingSafe');
@@ -16,10 +17,14 @@ const router = express.Router();
/**
* Generate a signed URL token for image access
*/
function generateImageToken(photoId, expiresIn = 3600) {
function generateImageToken(photoId, expiresIn = 3600, clientBypass = false) {
const secret = process.env.JWT_SECRET;
const expires = Date.now() + (expiresIn * 1000);
const data = `${photoId}:${expires}`;
// Third segment: whether the minter was a PIN-client, letting the serve
// route still deliver a photo hidden AFTER minting (TOCTOU) — a guest's
// token carries 0, so it stops working the moment the photo is hidden.
// Old two-segment tokens verify unchanged and read the flag as no-bypass.
const data = `${photoId}:${expires}:${clientBypass ? 1 : 0}`;
const signature = crypto.createHmac('sha256', secret).update(data).digest('hex');
return `${Buffer.from(data).toString('base64')}.${signature}`;
}
@@ -32,20 +37,23 @@ function verifyImageToken(token) {
const secret = process.env.JWT_SECRET;
const [data, signature] = token.split('.');
const decoded = Buffer.from(data, 'base64').toString();
const [photoId, expires] = decoded.split(':');
const [photoId, expires, clientFlag] = decoded.split(':');
// Verify signature (constant-time — avoids leaking the HMAC byte-by-byte)
const expectedSignature = crypto.createHmac('sha256', secret).update(decoded).digest('hex');
if (!timingSafeEqualStr(signature, expectedSignature)) {
return null;
}
// Check expiration
if (Date.now() > parseInt(expires)) {
return null;
}
return { photoId: parseInt(photoId), expires: parseInt(expires) };
return {
photoId: parseInt(photoId),
expires: parseInt(expires),
clientBypass: clientFlag === '1',
};
} catch (error) {
return null;
}
@@ -79,6 +87,12 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden/client-only photos (parity with the
// gallery single-photo routes).
if (isPhotoHiddenFromViewer(photo, req.accessLevel)) {
return res.status(403).json({ error: 'Photo not available' });
}
// Check for suspicious activity
const isSuspicious = await secureImageService.detectSuspiciousActivity(clientFingerprint, photoId);
if (isSuspicious) {
@@ -191,15 +205,23 @@ router.post('/:slug/photo/:photoId/generate-secure-token', verifyGalleryAccess,
return res.status(404).json({ error: 'Photo not found' });
}
// Don't mint a secure-image capability for a hidden/client-only photo
// when the caller isn't a client — the serve route is token-only.
if (isPhotoHiddenFromViewer(photo, req.accessLevel)) {
return res.status(403).json({ error: 'Photo not available' });
}
// Create client fingerprint
const clientFingerprint = secureImageService.createClientFingerprint(req);
// Generate secure token
// Generate secure token. clientBypass lets a client's token keep serving
// a photo hidden after minting; a guest's stops at the serve route.
const token = secureImageService.generateSecureToken(photoId, req.sessionID || 'anonymous', {
expiresIn,
maxUses: protectionLevel === 'maximum' ? 1 : 3,
clientFingerprint,
protectionLevel
protectionLevel,
clientBypass: canSeeHiddenPhotos(req.accessLevel)
});
res.json({
@@ -233,9 +255,19 @@ router.post('/:slug/photo/:photoId/generate-url', verifyGalleryAccess, async (re
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Generate signed token
const token = generateImageToken(photoId);
// Refuse to mint a signed URL for a hidden/client-only photo when the
// caller isn't a client. The signed-serve route below is token-only
// (no gallery auth), so the access decision has to happen here at mint
// time — mirroring how the reveal-bypass flag is baked into the token.
if (isPhotoHiddenFromViewer(photo, req.accessLevel)) {
return res.status(403).json({ error: 'Photo not available' });
}
// Generate signed token. The client-bypass flag lets a PIN-client's
// token keep serving a photo hidden after minting; a guest's token
// (clientBypass=0) stops the moment the photo is hidden.
const token = generateImageToken(photoId, 3600, canSeeHiddenPhotos(req.accessLevel));
const signedUrl = `/api/images/${req.params.slug}/photo/${photoId}/signed/${token}`;
res.json({
@@ -283,7 +315,14 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Recheck visibility at serve time (TOCTOU): a photo hidden AFTER the
// URL was minted must stop serving, unless the token was minted by a
// client (clientBypass) — mirroring the reveal-mode check above.
if (photo.visibility === 'hidden' && !tokenData.clientBypass) {
return res.status(403).json({ error: 'Photo not available' });
}
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
+75 -3
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');
@@ -13,6 +14,7 @@ const {
pickRawDownloadName,
} = require('../services/downloadFilenameService');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const { isPhotoHiddenFromViewer, canSeeHiddenPhotos } = require('../utils/photoVisibility');
const router = express.Router();
@@ -40,6 +42,12 @@ router.post('/:slug/generate-token', async (req, res, next) => {
return res.status(404).json({ error: 'Photo not found' });
}
// Don't mint a secure-image capability for a hidden/client-only photo
// when the caller isn't a client (the token is reusable up to 3×).
if (isPhotoHiddenFromViewer(photo, req.accessLevel)) {
return res.status(403).json({ error: 'Photo not available' });
}
// Create client fingerprint
const clientFingerprint = secureImageService.createClientFingerprint(req);
@@ -51,7 +59,10 @@ router.post('/:slug/generate-token', async (req, res, next) => {
expiresIn: protectionLevel === 'maximum' ? 180 : 300, // 3-5 minutes
maxUses: accessType === 'download' ? 1 : 3,
clientFingerprint,
protectionLevel
protectionLevel,
// TOCTOU: a client's token keeps serving a photo hidden after minting;
// a guest's stops the moment it's hidden (checked at the serve route).
clientBypass: canSeeHiddenPhotos(req.accessLevel)
};
const token = secureImageService.generateSecureToken(
@@ -137,6 +148,34 @@ router.get('/:slug/secure/:photoId/:token',
return res.status(404).json({ error: 'Gallery not found' });
}
// Bind the token to the gallery + photo it was minted for
// (GHSA-g94x-8vv8-3c9f). This route serves via <img src> with the
// token in the URL, so it can't require verifyGalleryAccess like the
// download sibling does. Instead enforce the scope already inside the
// token: it is minted for one photoId (and photos belong to exactly
// one gallery), and its sessionId records the minting gallery's id.
// Without this, a token minted on any PUBLIC gallery reads every other
// gallery's photos with no password.
const tokenPhotoId = Number(tokenValidation.data?.photoId);
if (!Number.isInteger(tokenPhotoId) || tokenPhotoId !== Number(photoId)) {
await secureImageService.logImageAccess(
photoId, event.id, req.clientInfo, 'photo_mismatch'
);
return res.status(403).json({ error: 'Token not valid for this photo' });
}
// Defense in depth: the sessionId embeds the gallery the token was
// minted for (`gallery_public_<id>_...` / `gallery_<id>_...`). Reject a
// token whose gallery is parseable and differs from this one.
const sessionEventId = Number(
(String(tokenValidation.data?.sessionId || '').match(/^gallery_(?:public_)?(\d+)_/) || [])[1]
);
if (Number.isInteger(sessionEventId) && sessionEventId !== Number(event.id)) {
await secureImageService.logImageAccess(
photoId, event.id, req.clientInfo, 'gallery_mismatch'
);
return res.status(403).json({ error: 'Token not valid for this gallery' });
}
// Verify photo exists and belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: event.id })
@@ -146,6 +185,13 @@ router.get('/:slug/secure/:photoId/:token',
return res.status(404).json({ error: 'Photo not found' });
}
// Recheck visibility at serve time (TOCTOU): a photo hidden AFTER the
// token was minted must stop serving, unless the token was minted by a
// client (clientBypass) — mirroring the reveal-mode check above.
if (photo.visibility === 'hidden' && !tokenValidation.data?.clientBypass) {
return res.status(403).json({ error: 'Photo not available' });
}
// Resolve photo through storage backend (managed) or fall back to local
// path (external reference mode). secureImageService needs a local file,
// so we materialize a tmp copy via withLocalCopy in S3 mode.
@@ -278,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' });
}
@@ -293,6 +340,14 @@ router.get('/:slug/secure-download/:photoId/:token',
return res.status(403).json({ error: 'Invalid or expired token' });
}
// Bind the token to the photo it was minted for (GHSA-crxv) — the
// /secure serve route does this, but secure-download did not, so a
// token minted for photo A could download photo B (incl. a hidden one).
const tokenPhotoId = Number(tokenValidation.data?.photoId);
if (!Number.isInteger(tokenPhotoId) || tokenPhotoId !== Number(photoId)) {
return res.status(403).json({ error: 'Token not valid for this photo' });
}
// Verify photo exists
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
@@ -302,6 +357,23 @@ router.get('/:slug/secure-download/:photoId/:token',
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden/client-only photos.
if (isPhotoHiddenFromViewer(photo, req.accessLevel)) {
return res.status(403).json({ error: 'Photo not available' });
}
// Per-category download opt-out (#640) — the regular single-photo
// download enforces this too; the secure path skipped it. SQLite
// returns the boolean as numeric 0, so check both forms.
if (photo.category_id) {
const cat = await db('photo_categories')
.where('id', photo.category_id)
.first('allow_downloads');
if (cat && (cat.allow_downloads === false || cat.allow_downloads === 0)) {
return res.status(403).json({ error: 'Downloads are disabled for this category' });
}
}
// Resolve photo through storage backend (managed) or local disk (external).
const storageKey = resolvePhotoStorageKey(req.event, photo);
@@ -43,10 +43,24 @@ jest.mock('../../../database/db', () => {
};
});
// RBAC is enforced on these routes since GHSA-9697 (requirePermission), but
// this suite mocks the database, so a real permission lookup would 500. These
// tests cover route logic, not authorization — the intersection of token
// scopes and role permissions is pinned in __tests__/routes/v1EventOwnership.
jest.mock('../../../middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
userHasAnyPermission: async () => true,
userHasAllPermissions: async () => true,
}));
jest.mock('../../../middleware/apiTokenAuth', () => ({
apiTokenAuth: (req, _res, next) => {
req.apiToken = { id: 1, admin_id: 1, scopes: ['write'] };
req.admin = { id: 1, username: 'token-admin' };
// roleName matters since GHSA-9697: requireEventOwnership now guards this
// route. super_admin short-circuits it without issuing a DB query, which
// keeps this suite's sequenced dbMock chains aligned — this suite is about
// category scoping, not ownership (see v1EventOwnership.test.js for that).
req.admin = { id: 1, username: 'token-admin', roleName: 'super_admin' };
next();
},
requireApiScope: () => (_req, _res, next) => next(),
@@ -51,6 +51,16 @@ jest.mock('../../../database/db', () => {
};
});
// RBAC is enforced on these routes since GHSA-9697 (requirePermission), but
// this suite mocks the database, so a real permission lookup would 500. These
// tests cover route logic, not authorization — the intersection of token
// scopes and role permissions is pinned in __tests__/routes/v1EventOwnership.
jest.mock('../../../middleware/permissions', () => ({
requirePermission: () => (_req, _res, next) => next(),
userHasAnyPermission: async () => true,
userHasAllPermissions: async () => true,
}));
jest.mock('../../../middleware/apiTokenAuth', () => ({
apiTokenAuth: (req, _res, next) => {
req.apiToken = { id: 1, admin_id: 1, scopes: ['admin'] };
+24 -6
View File
@@ -20,6 +20,15 @@ const sharp = require('sharp');
const { body, query, validationResult } = require('express-validator');
const { db, logActivity } = require('../../database/db');
const { apiTokenAuth, requireApiScope } = require('../../middleware/apiTokenAuth');
const { requireEventOwnership, scopeEventsQuery } = require('../../middleware/ownership');
// GHSA-9697: migration 081 defines a token's effective permissions as the
// INTERSECTION of the owner's role permissions and the token's scope flags.
// requireApiScope only ever checked the scope half — so a token minted while
// its owner was super_admin kept full write access after the owner was demoted
// to viewer (userManagementService never touches api_tokens). These
// requirePermission gates supply the missing half; they key on req.admin.id,
// which apiTokenAuth populates.
const { requirePermission } = require('../../middleware/permissions');
const { buildShareLinkVariants } = require('../../services/shareLinkService');
const { generateThumbnail } = require('../../services/imageProcessor');
const logger = require('../../utils/logger');
@@ -115,6 +124,7 @@ router.post(
'/events',
apiTokenAuth,
requireApiScope('admin'),
requirePermission('events.create'),
[
body('event_name').isString().trim().notEmpty(),
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other', 'family']),
@@ -419,6 +429,7 @@ router.get(
'/events',
apiTokenAuth,
requireApiScope('read'),
requirePermission('events.view'),
[
query('page').optional().isInt({ min: 1 }).toInt(),
query('limit').optional().isInt({ min: 1, max: 100 }).toInt()
@@ -429,14 +440,19 @@ router.get(
const limit = req.query.limit || 25;
const offset = (page - 1) * limit;
// Scope to events the token owner may see (GHSA-9697). Previously this
// listed every event on the instance regardless of who owned the token.
const [events, totalRow] = await Promise.all([
db('events')
.select('id', 'slug', 'event_name', 'event_type', 'event_date', 'expires_at',
'is_active', 'is_archived', 'is_draft', 'created_at')
scopeEventsQuery(
db('events')
.select('id', 'slug', 'event_name', 'event_type', 'event_date', 'expires_at',
'is_active', 'is_archived', 'is_draft', 'created_at'),
req.admin
)
.orderBy('created_at', 'desc')
.limit(limit)
.offset(offset),
db('events').count('id as count').first()
scopeEventsQuery(db('events').count('id as count'), req.admin).first()
]);
const total = parseInt(totalRow?.count || 0, 10);
res.json({ events, pagination: { page, limit, total } });
@@ -467,7 +483,7 @@ router.get(
* 200: { description: Event details }
* 404: { description: Not found }
*/
router.get('/events/:id', apiTokenAuth, requireApiScope('read'), async (req, res) => {
router.get('/events/:id', apiTokenAuth, requireApiScope('read'), requirePermission('events.view'), requireEventOwnership, async (req, res) => {
try {
const event = await db('events').where({ id: req.params.id }).first();
if (!event) return res.status(404).json({ error: 'Event not found' });
@@ -533,6 +549,8 @@ router.post(
'/events/:id/photos',
apiTokenAuth,
requireApiScope('write'),
requirePermission('photos.upload'),
requireEventOwnership,
photoUpload.single('photo'),
async (req, res) => {
let tempPath = null;
@@ -685,7 +703,7 @@ router.post(
* share_url: { type: string, format: uri }
* 404: { description: Not found }
*/
router.get('/events/:id/share-link', apiTokenAuth, requireApiScope('read'), async (req, res) => {
router.get('/events/:id/share-link', apiTokenAuth, requireApiScope('read'), requirePermission('events.view'), requireEventOwnership, async (req, res) => {
try {
const event = await db('events').where({ id: req.params.id }).first();
if (!event) return res.status(404).json({ error: 'Event not found' });
+6 -4
View File
@@ -1,4 +1,5 @@
const archiver = require('archiver');
const { neutralizeSpreadsheetFormula } = require('../utils/spreadsheetSafe');
const fs = require('fs');
const fsp = require('fs').promises;
const path = require('path');
@@ -261,12 +262,13 @@ function convertToCSV(data) {
const csvRows = data.map(row => {
return headers.map(header => {
const value = row[header];
// Escape quotes and wrap in quotes if contains comma
if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) {
// Formula-neutralize before quoting (guest_name/comment_text are
// user-controlled); the old check didn't even escape \n/\r (GHSA-q82f).
const value = neutralizeSpreadsheetFormula(row[header]);
if (value.includes(',') || value.includes('"') || value.includes('\n') || value.includes('\r')) {
return `"${value.replace(/"/g, '""')}"`;
}
return value || '';
return value;
}).join(',');
});
+168 -9
View File
@@ -134,7 +134,10 @@ class BackupManifestGenerator {
}
};
// Calculate total checksum of the manifest
// Calculate total checksum of the manifest. Records WHICH algorithm was
// used so validation can tell a keyed manifest from a legacy unkeyed one
// (GHSA-hgp8).
manifest.verification.checksum_algorithm = this.getManifestKey() ? 'hmac-sha256' : 'sha256';
manifest.verification.total_checksum = this.calculateManifestChecksum(manifest);
return manifest;
@@ -227,11 +230,15 @@ class BackupManifestGenerator {
throw new Error('File count mismatch');
}
// Validate total checksum
const calculatedChecksum = this.calculateManifestChecksum(manifest);
if (manifest.verification.total_checksum !== calculatedChecksum) {
throw new Error('Manifest checksum verification failed');
// Validate total checksum (GHSA-hgp8) — delegated so every caller shares
// the same fallback rules. restoreService.performPreRestoreValidation()
// used to recompute the digest itself with the default (canonical, keyed)
// settings, which silently rejected every pre-existing backup.
const checksumResult = this.verifyManifestChecksum(manifest);
if (!checksumResult.valid) {
throw new Error(checksumResult.error || 'Manifest checksum verification failed');
}
checksumResult.warnings.forEach((w) => logger.warn(w));
logger.info('Manifest validation passed');
return true;
@@ -327,6 +334,7 @@ class BackupManifestGenerator {
// otherwise validateManifest() rejects the loaded manifest because
// generateManifest() stamped a checksum that did NOT include this
// section.
fullManifest.verification.checksum_algorithm = this.getManifestKey() ? 'hmac-sha256' : 'sha256';
fullManifest.verification.total_checksum = this.calculateManifestChecksum(fullManifest);
return fullManifest;
@@ -439,16 +447,167 @@ class BackupManifestGenerator {
}
}
calculateManifestChecksum(manifest) {
/**
* GHSA-hgp8: the plain SHA-256 below proves the manifest wasn't CORRUPTED,
* not that it is AUTHENTIC anyone who can rewrite the file can recompute
* it. Setting BACKUP_MANIFEST_KEY upgrades new manifests to a keyed HMAC,
* which matters when the backup store is a different trust domain from the
* host (S3 bucket creds != host creds).
*
* Deliberately OPT-IN and verify-if-present: the key cannot live in the
* database (the database is inside the backup), so a mandatory HMAC would
* lock an operator out of the exact disaster-recovery case this system
* exists for total host loss, fresh install, only the backup survives.
* Unkeyed manifests therefore still validate, and a keyed manifest is only
* held to the keyed check when a key is configured.
*/
getManifestKey() {
const key = process.env.BACKUP_MANIFEST_KEY;
return typeof key === 'string' && key.trim() ? key.trim() : null;
}
/**
* Single source of truth for "does this manifest's checksum verify?"
* (GHSA-hgp8). Returns a result object rather than throwing so callers can
* surface warnings without duplicating the fallback rules a duplicated
* check in restoreService recomputed the digest with the default canonical
* serializer and rejected every manifest written before that change.
*
* Rules, in order:
* - keyed manifest + no key configured cannot verify; accept with a
* loud warning (refusing would brick recovery when the key was lost with
* the host, which is exactly when a restore is needed), UNLESS
* BACKUP_MANIFEST_REQUIRE_KEYED is set.
* - unkeyed manifest + key configured possible downgrade. Accepted with
* a warning by default for backward compatibility; rejected when
* BACKUP_MANIFEST_REQUIRE_KEYED is set, which is the setting an operator
* turns on once all their backups are keyed.
* - digest mismatch retry with the legacy (pre-canonicalization)
* serialization so old backups stay restorable, then fail.
* - no checksum at all reject. Every manifest this codebase has ever
* written stamps `verification.total_checksum` (generateManifest and
* the incremental path both do), so an absent one means the manifest
* was rewritten and accepting it would let an attacker strip the
* field to skip verification entirely, walking straight past both the
* downgrade guard and BACKUP_MANIFEST_REQUIRE_KEYED.
*
* @returns {{valid: boolean, error?: string, warnings: string[]}}
*/
verifyManifestChecksum(manifest) {
const warnings = [];
if (!manifest?.verification?.total_checksum) {
return {
valid: false,
error: 'Manifest carries no checksum — refusing to treat an unverifiable manifest as authentic',
warnings,
};
}
const declaredAlgorithm = manifest.verification.checksum_algorithm || 'sha256';
const key = this.getManifestKey();
const requireKeyed = /^(1|true|yes)$/i.test(String(process.env.BACKUP_MANIFEST_REQUIRE_KEYED || ''));
if (declaredAlgorithm === 'hmac-sha256' && !key) {
if (requireKeyed) {
return {
valid: false,
error: 'Manifest is keyed but BACKUP_MANIFEST_KEY is not set (BACKUP_MANIFEST_REQUIRE_KEYED is on)',
warnings,
};
}
warnings.push(
'Manifest declares a keyed checksum but BACKUP_MANIFEST_KEY is not set — '
+ 'authenticity cannot be verified. Set the key to enable verification.'
);
return { valid: true, warnings };
}
// Downgrade guard: with a key configured, an attacker who can rewrite the
// backup store could otherwise strip checksum_algorithm, edit the manifest
// and recompute a plain SHA-256 that we would happily accept. Rejecting
// that by default would break every pre-key backup, so it is opt-in.
//
// The strict rejection must NOT be conditional on a key being configured:
// strict mode is a statement about the manifests ("all mine are keyed"),
// not about this host. Gating it on `key` made the flag fail open on
// exactly the fresh disaster-recovery host that is missing the secret.
if (declaredAlgorithm !== 'hmac-sha256') {
if (requireKeyed) {
return {
valid: false,
error: 'Manifest is not keyed but BACKUP_MANIFEST_REQUIRE_KEYED is on — refusing a possible checksum downgrade',
warnings,
};
}
if (key) {
warnings.push(
'Manifest uses an unkeyed checksum while BACKUP_MANIFEST_KEY is set — integrity verified, '
+ 'authenticity NOT established (a rewritten manifest could have downgraded the algorithm). '
+ 'Set BACKUP_MANIFEST_REQUIRE_KEYED=true once all backups are keyed.'
);
}
}
const keyedArg = declaredAlgorithm === 'hmac-sha256' ? key : false;
const expected = manifest.verification.total_checksum;
if (expected === this.calculateManifestChecksum(manifest, { keyed: keyedArg })) {
return { valid: true, warnings };
}
// Pre-canonicalization manifests hashed a serialization that omitted
// nested fields; accept those so existing backups stay restorable.
if (expected === this.calculateManifestChecksum(manifest, { keyed: keyedArg, legacy: true })) {
warnings.push(
'Manifest uses the legacy checksum serialization, which did not cover the file list — '
+ 'integrity of file paths/sizes is unverified. Re-run a backup to upgrade it.'
);
return { valid: true, warnings };
}
return { valid: false, error: 'Manifest checksum verification failed', warnings };
}
/**
* Canonical JSON: object keys sorted recursively so the digest is stable
* regardless of property insertion order, and critically so NESTED
* values are actually covered.
*
* The previous implementation passed `Object.keys(manifest).sort()` as
* JSON.stringify's second argument. That parameter is an array *replacer*
* (a property allowlist applied at every depth), not a key sorter, so every
* nested key absent from that top-level list `path`, `size`, per-file
* `checksum` was dropped before hashing. The file list was therefore
* outside the "integrity" check entirely: a manifest path could be rewritten
* to `../../etc/passwd` without disturbing the checksum.
*/
canonicalize(value) {
if (Array.isArray(value)) return value.map((v) => this.canonicalize(v));
if (value && typeof value === 'object') {
return Object.keys(value).sort().reduce((acc, k) => {
acc[k] = this.canonicalize(value[k]);
return acc;
}, {});
}
return value;
}
calculateManifestChecksum(manifest, { keyed = null, legacy = false } = {}) {
// Create a copy without the checksum field
const manifestCopy = JSON.parse(JSON.stringify(manifest));
if (manifestCopy.verification) {
delete manifestCopy.verification.total_checksum;
delete manifestCopy.verification.checksum_algorithm;
}
// Calculate SHA256 of the sorted JSON
const content = JSON.stringify(manifestCopy, Object.keys(manifestCopy).sort());
return crypto.createHash('sha256').update(content).digest('hex');
// `legacy` reproduces the old (under-covering) serialization so manifests
// written by earlier versions still validate — see validateManifest.
const content = legacy
? JSON.stringify(manifestCopy, Object.keys(manifestCopy).sort())
: JSON.stringify(this.canonicalize(manifestCopy));
const key = keyed === null ? this.getManifestKey() : keyed;
return key
? crypto.createHmac('sha256', key).update(content).digest('hex')
: crypto.createHash('sha256').update(content).digest('hex');
}
/**
+8
View File
@@ -845,6 +845,14 @@ function parseRsyncStats(output) {
async function performRsyncBackup(config, files) {
const { spawnAsync } = require('../utils/safeExec');
// SSRF: the /test-connection route validates the host, but a scheduled or
// manual /run reaches here directly with the stored host. Resolve-and-vet
// it right before ssh/rsync does its own DNS at connect time, so a host
// that resolves to an internal address can't be reached (GHSA-4jh8).
const { isHostAllowed } = require('../utils/networkValidation');
if (!(await isHostAllowed(config.backup_rsync_host))) {
throw new Error('rsync host resolves to a private or internal network address');
}
// Anchored excludes for the de-selected What-to-Backup paths; rsync
// otherwise transfers the whole storage root regardless of the walker's
// file list (which only feeds manifests and file state).
+6 -2
View File
@@ -281,8 +281,12 @@ async function cleanupExpiredUploads() {
return expiredIds.length;
}
// Run cleanup every hour
setInterval(cleanupExpiredUploads, 60 * 60 * 1000);
// Run cleanup every hour. unref so this module-level housekeeping timer
// never holds the process open on its own — in production the HTTP
// listener keeps the loop alive, and in Jest this exact handle kept the
// runner from exiting for every suite that requires adminPhotos (#908;
// it is why adminPhotos.reference sits on the CI ignore list).
setInterval(cleanupExpiredUploads, 60 * 60 * 1000).unref();
module.exports = {
initializeUpload,
+2 -2
View File
@@ -205,7 +205,7 @@ async function createContract(payload, adminId) {
}
const inserted = await trx('contracts').insert(row).returning('id');
if (row.project_id && row.deal_uuid) {
await require('../projectService').linkDealToProject(row.deal_uuid, row.project_id, trx);
await require('../projectService').linkDealToProject(row.deal_uuid, row.project_id, trx, { id: adminId });
}
const contractId = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
@@ -302,7 +302,7 @@ async function updateContract(id, payload, adminId) {
// Cascade across the deal lineage (linked quote / event / invoices).
if (updates.project_id) {
const dealRow = await trx('contracts').where({ id }).select('deal_uuid').first();
await require('../projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx);
await require('../projectService').linkDealToProject(dealRow && dealRow.deal_uuid, updates.project_id, trx, { id: adminId });
}
// Replace inclusions only when the caller sent an explicit list.
@@ -112,8 +112,15 @@ class DownloadZipService {
const event = await db('events').where({ id: eventId }).first();
if (!event) return { success: false, error: 'Event not found' };
// The prebuilt zip is served to ordinary gallery guests (the
// download-all fast path), so it must exclude hidden/client-only
// photos — NULL visibility counts as visible (pre-migration rows).
// PIN-clients bypass this cache and stream a full archive instead.
const photos = await db('photos')
.where({ event_id: eventId })
.where(function () {
this.where('visibility', 'visible').orWhereNull('visibility');
})
.select('*')
.orderBy('type', 'asc')
.orderBy('uploaded_at', 'desc');
+76 -4
View File
@@ -20,6 +20,31 @@ const sanitizeHtml = require('sanitize-html');
const { isUniqueViolation } = require('../utils/dbErrors');
const ALLOWED_MIME = ['application/pdf', 'image/jpeg', 'image/png'];
// Resource caps for inbound mail (GHSA-2qf9). Anyone who can email the
// operator's mailbox reaches this code path unauthenticated, and nothing here
// used to bound message size, attachment count or attachment bytes. Defaults
// are generous for real supplier invoices; all three are env-overridable.
const numFromEnv = (name, fallback) => {
const n = Number(process.env[name]);
return Number.isFinite(n) && n > 0 ? n : fallback;
};
const MAX_MESSAGE_BYTES = numFromEnv('EMAIL_INTAKE_MAX_MESSAGE_BYTES', 25 * 1024 * 1024);
// received_emails.message_id is varchar(512) WITH a UNIQUE constraint. A sender
// can legally emit a Message-ID longer than that; the insert then throws, the
// catch path stores a synthetic err-<uid>-<now> key that can never match the
// dedup pass, and every poll re-downloads and re-parses the same message
// forever. Collapse anything overlong to a stable hash so the key always fits
// and always reproduces (GHSA-2qf9).
const MESSAGE_ID_MAX = 512;
const boundedMessageId = (raw, fallback) => {
const value = String(raw || fallback || '').trim() || String(fallback || '');
if (value.length <= MESSAGE_ID_MAX) return value;
return `sha256:${require('crypto').createHash('sha256').update(value).digest('hex')}`;
};
const MAX_ATTACHMENTS = numFromEnv('EMAIL_INTAKE_MAX_ATTACHMENTS', 25);
const MAX_ATTACHMENT_BYTES = numFromEnv('EMAIL_INTAKE_MAX_ATTACHMENT_BYTES', 25 * 1024 * 1024);
let polling = false;
// Fail fast instead of hanging on a wrong host/port (e.g. IMAP pointed at an
@@ -280,8 +305,17 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
const candidates = [];
if (uids.length) {
// eslint-disable-next-line no-restricted-syntax
for await (const m of client.fetch(uids, { uid: true, envelope: true }, { uid: true })) {
candidates.push({ uid: m.uid, messageId: (m.envelope && m.envelope.messageId) || `uid-${cfg.folder}-${m.uid}` });
// `size` rides along in the same cheap envelope pass, so an oversized
// message can be rejected BEFORE its source is downloaded (GHSA-2qf9).
for await (const m of client.fetch(uids, { uid: true, envelope: true, size: true }, { uid: true })) {
candidates.push({
uid: m.uid,
size: Number(m.size) || 0,
messageId: boundedMessageId(
m.envelope && m.envelope.messageId,
`uid-${cfg.folder}-${m.uid}`,
),
});
}
}
@@ -300,10 +334,30 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
let claimKey = null;
let claimed = false;
try {
// Refuse oversized messages before download (GHSA-2qf9). Recorded
// under the REAL message id — not a synthetic err-<uid>-<now> key —
// so the step-3 dedup skips it on the next poll. Without that, the
// same huge message was re-downloaded every poll interval forever,
// and an OOM-kill/restart simply resumed the loop.
if (MAX_MESSAGE_BYTES > 0 && cand.size > MAX_MESSAGE_BYTES) {
logger.warn?.(`emailIntake: skipping uid ${cand.uid}${cand.size} bytes exceeds the ${MAX_MESSAGE_BYTES}-byte limit`);
await db('received_emails').insert({
message_id: cand.messageId,
account_key: accountKey,
status: 'error',
error: `Message too large (${cand.size} bytes); limit is ${MAX_MESSAGE_BYTES}`,
attachment_count: 0,
received_at: new Date(),
created_at: new Date(),
});
await client.messageFlagsAdd(cand.uid, ['\\Seen'], { uid: true });
continue;
}
const one = await client.fetchOne(String(cand.uid), { source: true }, { uid: true });
if (!one || !one.source) continue;
const parsed = await simpleParser(one.source);
messageId = parsed.messageId || cand.messageId;
messageId = boundedMessageId(parsed.messageId, cand.messageId);
// Claim key: a no-Message-ID mail still needs a non-null, per-message
// key so two pollers converge — fall back to the mailbox uid.
claimKey = messageId || `nomsgid-${cand.uid}`;
@@ -347,7 +401,25 @@ async function pollAccountOnce(cfg, { accountKey = 'accounting', routeToExpenses
let count = 0;
const attErrors = [];
if (routeToExpenses) {
const atts = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType));
const allowed = (parsed.attachments || []).filter((a) => ALLOWED_MIME.includes(a.contentType));
// Cap attachment count AND cumulative bytes (GHSA-2qf9) — a single
// in-limit message can still carry hundreds of attachments, each
// written to disk by saveAttachment().
const atts = [];
let attBytes = 0;
for (const att of allowed) {
if (atts.length >= MAX_ATTACHMENTS) {
attErrors.push(`Attachment limit reached (${MAX_ATTACHMENTS}); remaining attachments skipped`);
break;
}
const size = att.content ? att.content.length : 0;
if (attBytes + size > MAX_ATTACHMENT_BYTES) {
attErrors.push(`Cumulative attachment size limit reached (${MAX_ATTACHMENT_BYTES} bytes); remaining attachments skipped`);
break;
}
attBytes += size;
atts.push(att);
}
for (const att of atts) {
try {
const filePath = await saveAttachment(att);
+20 -11
View File
@@ -22,6 +22,15 @@ const { AppError } = require('../utils/errors');
const logger = require('../utils/logger');
const invoiceService = require('./invoiceService');
/**
* Actor for logActivity. `adminId` is legitimately absent on automated paths
* emailIntakeService calls recordInboundDocument() with none and an
* unconditional `{ type: 'admin' }` would store actor_type='admin' with a null
* id, mislabelling mailbox captures as somebody's deliberate action. Returning
* null restores logActivity's 'system' attribution for those.
*/
const adminActor = (adminId) => (adminId ? { type: 'admin', id: adminId } : null);
const DISPOSITIONS = ['rebill', 'durchlaufend', 'eigener_aufwand', 'duplikat', 'abgelehnt'];
const TAX_TREATMENTS = ['domestic', 'reverse_charge_service', 'foreign_vat_non_reclaimable', 'import_goods'];
const MARKUP_TYPES = ['none', 'percent', 'flat'];
@@ -193,7 +202,7 @@ async function recordInboundDocument({ source, filePath, originalFilename, mimeT
};
const inserted = await db('inbound_documents').insert(row).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
await logActivity('incoming_invoice_captured', { inboundDocumentId: id, source: row.source, duplicate: !!duplicateOfId }, adminId);
await logActivity('incoming_invoice_captured', { inboundDocumentId: id, source: row.source, duplicate: !!duplicateOfId }, null, adminActor(adminId));
return getInbound(id);
}
@@ -242,7 +251,7 @@ async function updateInbound(id, payload, adminId) {
if (payload[camel] !== undefined) patch[snake] = payload[camel] === '' ? null : payload[camel];
}
await db('inbound_documents').where({ id }).update(patch);
await logActivity('incoming_invoice_updated', { inboundDocumentId: id }, adminId);
await logActivity('incoming_invoice_updated', { inboundDocumentId: id }, null, adminActor(adminId));
return getInbound(id);
}
@@ -458,8 +467,8 @@ async function categorizeInbound(id, payload, adminId) {
});
// Audit logging AFTER commit — logActivity writes via the global db and would
// deadlock if run inside the transaction above on a SQLite-backed install.
await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, adminId);
if (billedInvoiceId) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId: billedInvoiceId }, adminId);
await logActivity('incoming_invoice_categorized', { inboundDocumentId: id, disposition }, null, adminActor(adminId));
if (billedInvoiceId) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId: billedInvoiceId }, null, adminActor(adminId));
return getInbound(id);
}
@@ -494,7 +503,7 @@ async function rebillInbound(id, payload, adminId, trx0) {
const invoiceId = trx0 ? await run(trx0) : await db.transaction(run);
// Log after commit (global-db write — see billInboundNow). When a caller
// supplied trx0, that outer transaction owns the audit log instead.
if (!trx0) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, adminId);
if (!trx0) await logActivity('incoming_invoice_rebilled', { inboundDocumentId: id, invoiceId }, null, adminActor(adminId));
return { document: await getInbound(id), invoiceId };
}
@@ -607,7 +616,7 @@ async function billPendingRebills(customerId, adminId) {
return { invoiceId, count: pending.length };
});
// Audit log after commit (global-db write — see billInboundNow).
await logActivity('incoming_invoices_rebilled_bundle', { customerId: customer.id, invoiceId: result.invoiceId, count: result.count }, adminId);
await logActivity('incoming_invoices_rebilled_bundle', { customerId: customer.id, invoiceId: result.invoiceId, count: result.count }, null, adminActor(adminId));
return result;
}
@@ -624,7 +633,7 @@ async function markInboundSupplierPayment(id, { paid, paidAt, paymentMethod, pay
supplier_payment_ref: paid ? (paymentReference || null) : null,
updated_at: new Date(),
});
await logActivity('incoming_invoice_supplier_payment', { inboundDocumentId: id, paid: !!paid }, adminId);
await logActivity('incoming_invoice_supplier_payment', { inboundDocumentId: id, paid: !!paid }, null, adminActor(adminId));
return getInbound(id);
}
@@ -714,7 +723,7 @@ async function createExpense(payload, adminId, { receiptPath } = {}) {
});
const inserted = await db('expenses').insert(row).returning('id');
const id = typeof inserted[0] === 'object' ? inserted[0].id : inserted[0];
await logActivity('expense_created', { expenseId: id, kind: row.kind }, adminId);
await logActivity('expense_created', { expenseId: id, kind: row.kind }, null, adminActor(adminId));
return getExpense(id);
}
@@ -747,7 +756,7 @@ async function updateExpense(id, payload, adminId, { receiptPath } = {}) {
}
if (receiptPath) patch.receipt_path = receiptPath;
await db('expenses').where({ id }).update(patch);
await logActivity('expense_updated', { expenseId: id }, adminId);
await logActivity('expense_updated', { expenseId: id }, null, adminActor(adminId));
return getExpense(id);
}
@@ -787,7 +796,7 @@ async function rebillExpense(id, payload, adminId, trx0) {
status: 'invoiced',
updated_at: new Date(),
});
await logActivity('expense_invoiced', { expenseId: id, invoiceId }, adminId);
await logActivity('expense_invoiced', { expenseId: id, invoiceId }, null, adminActor(adminId));
return invoiceId;
};
const invoiceId = trx0 ? await run(trx0) : await db.transaction(run);
@@ -807,7 +816,7 @@ async function markExpensePaid(id, { paid, paidAt, paymentMethod, paymentReferen
payment_reference: paid ? (paymentReference || null) : null,
updated_at: new Date(),
});
await logActivity('expense_paid', { expenseId: id, paid: !!paid }, adminId);
await logActivity('expense_paid', { expenseId: id, paid: !!paid }, null, adminActor(adminId));
return getExpense(id);
}
+49 -11
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);
@@ -400,7 +434,7 @@ class FeedbackService {
/**
* Get feedback requiring moderation
*/
async getPendingModeration(eventId = null) {
async getPendingModeration(eventId = null, ownedEventIds = null) {
try {
let query = db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
@@ -408,9 +442,13 @@ class FeedbackService {
.where('photo_feedback.is_approved', false)
.where('photo_feedback.is_hidden', false)
.where('photo_feedback.feedback_type', 'comment');
if (eventId) {
query = query.where('photo_feedback.event_id', eventId);
} else if (Array.isArray(ownedEventIds)) {
// Scope to the caller's owned events (GHSA-3335) — an empty set
// matches nothing, so a restricted admin sees only their own.
query = query.whereIn('photo_feedback.event_id', ownedEventIds.length ? ownedEventIds : [-1]);
}
const pending = await query

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