* fix(gallery): make per-event banner overrides actually work, both banners (#440, #932)
The promo banner shipped with a per-event inherit/custom/off override that
never reached a guest. GalleryView reads promo_mode from the /photos payload,
and /photos never sent it — so every gallery resolved to 'inherit'. Setting a
gallery's promo banner to "Off" did nothing; the global banner kept rendering.
The info banner (#932) mirrored that shape and inherited the same gaps.
Four places dropped the fields; all four now carry both banners:
1. GET /gallery/:slug/photos — send promo_mode/promo_markdown alongside the
info fields. This is the fix that makes "Off" mean off.
2. POST /admin/events — the validators accepted both banners and the insert
discarded them, so an API client could POST info_mode:'off', get 201, and
find the row on 'inherit'. Markdown is stored only for 'custom', matching
the PUT rule.
3. POST /admin/events/:id/duplicate — copy both from the source row. The
dialog promises the copy "inherits the branding, behaviour, feedback, and
category configuration"; a muted gallery un-muting on duplication is the
opposite of that.
4. PUT /admin/events/:id — resolve the effective mode from the STORED row when
a partial update sends only the markdown. Previously updates.promo_mode was
undefined on such a request and the text was parked on an inherit/off
gallery, then resurfaced when someone later switched it to 'custom'. The
lookup is lazy: one extra query, only on that path.
The two normalisation blocks are now one loop over both banners, so the pair
can't drift apart again.
Verified in a browser, both directions against the same global banner:
promo_mode='off' -> not rendered; 'inherit' -> rendered. The /photos payload
went from promo_mode ABSENT to carrying the value.
* fix(gallery): thread promo into the reveal view, drop stale markdown on duplicate
External review, round 1 on this PR. Two gaps in the plumbing it introduced:
- The reveal-hidden branch copied only the info fields from /photos. Now that
/photos carries promo too, a reveal-hidden gallery with promo_mode 'off'
still fell back to 'inherit' and showed the global banner on the first load
after login. Thread both banners there.
- The duplicate copied markdown verbatim. A row written before the PUT
normalisation landed can hold text while its mode is 'inherit'/'off', so the
copy inherited hidden text that would resurface the moment someone switched
it to 'custom' — violating the very invariant this PR establishes. Copy
markdown only when the source mode is 'custom'.
Test covers the stale-markdown source explicitly.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(gallery): info banner above the photo grid (#932)
A short informational note rendered at the TOP of a gallery, above the
photos. Distinct from the promotional banner (#440), which stays by the
footer for marketing copy — the reporter's case is an onboarding hint ("use
the menu button to filter"), which is useless below a gallery the guest has
to scroll past first.
Mirrors the promo feature's shape rather than inventing a second one: a
global default in Settings → Branding (branding_info_markdown) plus a
per-event inherit/custom/off override. Markdown via the existing
MarkdownContent sanitiser — no raw HTML, no CSS injection. Empty global
default means nothing renders, so upgrading changes nothing visible.
Deliberately NOT included: an alignment knob (this is short helper copy, not
marketing layout) and guest dismissal — the issue lists dismissal as a
nice-to-have, and it needs per-guest persistence that is its own decision.
Migration 176 is idempotent (hasColumn / existing-key guarded).
Note on the payload plumbing: the per-event fields travel in the /photos
response, not just /info. GalleryAuthContext seeds its cached event from the
gallery LOGIN response — a small identity subset — so anything absent there
is undefined right after a guest signs in. /photos is the payload that
refreshes on every gallery load, which is why the fields were added there
and why GalleryView reads them from `data.event`. Verified in a browser
across all three modes; reading them from the context event instead silently
collapsed every override back to 'inherit'.
* fix(branding): map branding_info_markdown on read so saving can't wipe it (#932)
External review caught this. BrandingSettings declared no info_markdown and
formatBrandingSettings never mapped branding_info_markdown, so BrandingPage's
hydration — setBrandingSettings(prev => ({ ...prev, ...formatted })) — kept
the empty-string initializer instead of the persisted value. The form loaded
blank and the next Save posted '' back, wiping a configured banner. Silently:
the gallery keeps rendering the old copy until that save lands.
This is the same bug the footer/promo fields hit in #441 + #440 / #460, which
the read mapper still carries a comment about. Add the field to the interface
and the mapper, and pin the round-trip for the whole editable branding set so
the next field added is caught by a test rather than by a user losing copy.
Verified: the new test fails 3/4 with the mapper line removed.
* fix(gallery): honour the info-banner override in the reveal-hidden view (#932)
External review, round 2. The hidden-until-reveal branch renders GalleryLayout
with the context `event`, which is seeded from the gallery login response and
carries no banner fields — so while a gallery was hidden, a per-event 'off'
silently resolved to 'inherit' and the global banner appeared on a gallery the
admin had muted.
Resolve the fields there the same way the main render path does. The two
full-page layouts (gallery-premium, gallery-story) are deliberately left alone:
they return before GalleryLayout and render no header, footer or promo banner
either — injecting a wrapper into layouts documented as having 'their own
integrated UI' would be a design change, not a fix.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(pdf): RFC 6266-encode Content-Disposition on quote/invoice PDFs (#1024)
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.
Note this corrects the issue's diagnosis: it reported umlauts as the 500
case, but umlauts are inside latin1 and mangle rather than throw. Both
symptoms share this root cause and both are fixed here.
Route through buildContentDisposition() (utils/filenameSanitizer, already
used by secureImages.js), which emits an ASCII fallback plus the RFC 5987
`filename*=UTF-8''…` form, so the unicode name survives in browsers and the
header stays legal. Applied to all six sites: adminQuotes (persisted +
preview), adminInvoices (persisted + preview), customer (quote + invoice).
Also correct buildPdfFilename's docstring, which advertised the preserved
non-ASCII as suitable for Content-Disposition — the exact misreading that
produced these call sites.
* test(pdf): pin the ASCII fallback for fully non-Latin customer names (#1024)
A name written entirely in another script leaves the legacy filename= token
with just the document number (Q-2026-0042_.pdf) — filename* carries the real
name. That's the intended trade, but it's the token a client without RFC 5987
support actually saves, so assert it stays legal, non-empty and carries the
document number rather than leaving it unpinned.
* fix(pdf): don't split surrogate pairs when truncating the filename (#1024)
Codex review caught this. sanitiseSegment caps each segment at 80 UTF-16 code
units, so a cap landing inside an astral character (emoji, rarer CJK) left a
dangling high surrogate. encodeURIComponent throws URIError: URI malformed on
a lone surrogate, so buildContentDisposition — the helper this PR routes the
six PDF endpoints through — 500'd for e.g. company_name = 'a'.repeat(79)+'🎉',
well inside the 120-char validator limit. Same 500 the PR set out to remove,
reached a different way.
Drop the orphaned surrogate instead of widening the cap, so the byte budget
the limit exists to protect is unchanged. Tests cover both boundary cases and
assert the cap semantics; they fail against the previous slice().
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Reshaped onto main after #1039 landed the coercion engine
(typedColumnsFor / epochToIso / coerceForTargetEngine) — this PR is now
only the policy delta on top of it:
- validateManifest: replace the CLI-only allowEngineSwitch opt-in with a
direction rule — sqlite → pg allowed (upload UI and CLI alike),
pg → sqlite refused with a message naming the supported direction
- importFromPicpeak: derive crossEngine from the manifest's engine
(absent field = target engine, the exact pre-change behavior), log it,
return it; route passes it through
- scripts/migrate-sqlite-to-postgres.js: rely on the shared gate, drop
the flag
- restore card: direction stated in the intro, cross-engine notice after
a converting restore; both strings in en.json + de.json; removed the
orphaned settings.backup.picpeak locale node (unreferenced, stale copy)
- picpeakCrossEngine.test.js: direction policy, epochToIso (ms, seconds,
numeric strings), coerceForTargetEngine units, plus
PICPEAK_PG_TEST_URL-gated real-Postgres stored-value assertions
Co-authored-by: Paul Nothaft <53005142+the-luap@users.noreply.github.com>
The .picpeak restore suites gate their Postgres cases behind
PICPEAK_PG_TEST_URL and describe.skip themselves out when it is unset. That
variable was set in no workflow, so those cases have never run in CI — the
suites reported green while silently skipping the half that needs a real
database: sequence resync, operator/role preservation across a cross-instance
restore, and whether a coerced row lands with the right STORED VALUES rather
than merely not throwing.
Add a postgres:15-alpine service to the backend job (same shape schema-drift
already uses) and point the variable at it. Everything else in the suite still
runs on SQLite; this only un-gates the cases that were skipping.
Verified against a real Postgres 15 before wiring: picpeakRestorePg 4/4 and
picpeakCrossEngine 11/11 (8 of which were previously skipped across both).
Matters now because #1043 opens sqlite -> pg restore to the upload UI, so the
coercion layer's correctness stops being a CLI-only concern.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(storage): add S3 client timeouts so a dropped connection can't wedge uploads
* fix(storage): use socketTimeout, not requestTimeout, for the dead-connection guard
* fix(storage): make S3 timeouts generous — short connectionTimeout breaks pooled reads
---------
Co-authored-by: Peifu Mo <peipeimo@Peifus-MacBook-Pro.local>
* feat(permissions): granular permission gating + role editor & presets
Make every admin feature permission-gateable so multi-user studios can
split capability across roles (#747, and phase 1 of #743).
- Split the catch-all settings.edit into dedicated dangerous-config perms
(banking / domains / security / integrations / features): a team member
can no longer change IBAN, domains, SSO, webhooks, API tokens or feature
flags. Reads keep an OR with settings.view so existing roles keep
visibility. The site-URL write inside /general is change-gated on
settings.domains.
- Add dedicated perms for admin surfaces miscategorised under settings.*
(whatsapp, event_types, image_security, notifications, system) plus
roles.manage and vat_codes.view; gate the previously-ungated VAT read.
- Boot self-heal (_permissionsBoot.js): super_admin always holds every
permission (tracks-all) so new perms never need a compensation
migration; all other roles stay frozen (no silent escalation on upgrade).
- Seed two presets: Solo Photographer (full operator) and Team
Photographer (contributor — view events + manage photos + read-only CRM;
no settings/users/billing edits, no events.edit).
- Role editor: adminRoles CRUD (create/edit/clone/delete + permission
matrix; system roles protected, super_admin immutable) and a Roles tab
with a category-grouped matrix and preset cloning.
- Settings page tabs are permission-gated with snap-back; i18n en/de.
Migration 174. Backward-compatible: admin/editor/viewer unchanged.
* feat(permissions): hide in-page action buttons a role can't use
Wrap mutating controls on the surfaces restricted roles actually reach
(Events list, Archives, gallery photo grid, event detail) in
PermissionGate so they are HIDDEN when the user lacks the permission,
rather than shown-then-403:
- Events list: create / bulk archive / bulk delete / row archive /
row delete / download-archive.
- Archives: restore / download / delete.
- Photo grid: single + bulk delete (photos.delete), per-photo download
(photos.download), bulk move/hide/show (photos.edit).
- Event detail: edit / rename / publish (events.edit), duplicate
(events.create), archive (events.archive), create-invoice
(bills.manage); the Actions card is hidden entirely for view-only roles.
- Photos tab: upload / external import (photos.upload), export menu
(photos.download).
Backend already enforces these with 403; this is the matching UX so a
Team Photographer never sees delete/settings controls.
* fix(permissions): close settings-split bypass via generic settings writers
Security review found the settings.edit split was bypassable: the generic
settings writers (/general, /analytics, /seo, /security) upsert arbitrary
setting_keys, so a role holding only settings.edit (or settings.security)
could write keys owned by a narrower permission — repointing the public
site URL (settings.domains), security policy (settings.security) or
VAT/accounting config (settings.banking) via the wrong endpoint.
Add stripUnauthorizedProtectedKeys(): before every generic upsert, drop
any protected key the caller isn't permitted to write (general_site_url →
settings.domains, security_* → settings.security, accounting_* →
settings.banking). Dedicated routes still work because their caller holds
the matching perm. Replaces the narrower in-handler site-URL guard.
Also fix two tests affected by the RBAC changes:
- authzPermissionGaps: API-token management moved to settings.integrations,
so grant that (not settings.edit) to exercise the ownership 404.
- AdminPhotoGrid.viewToggle: stub PermissionGate (its buttons are now gated
and the test renders without a PermissionsProvider).
* fix(permissions): address upstream review (#1045)
- Renumber migration 174 -> 175 (174 now taken by 174_sqlite_nullable_event_dates
from #1035; the collision made picpeakImportService's forward-only restore
guard treat both as order 174 and accept a newer .picpeak onto an older schema).
- Contain the roles.manage blast radius (delegation, not root escalation): a
non-super_admin can no longer edit their own role, nor grant any permission
their own role doesn't already hold (createRole + updateRole).
- Protected-key denial now 403s (naming the keys + required perms) instead of
silently stripping and reporting "saved" (adminSettings generic writers).
- Reserve team_photographer so a custom role can't squat the preset name.
- Boot self-heal: per-step try/catch so a role_permissions insert race on one
replica doesn't skip preset seeding.
- Forward-project the feature .manage perms that also replaced settings.edit
gates (whatsapp/event_types/image_security/notifications/system), matching the
settings.* split projection so the pattern is symmetric for phase-2.
- Guard exports.down's roles/admin_users queries with hasTable.
* fix(permissions): change-detection on protected-key 403 + commit guard tests (#1045)
Round-2 review:
- The protected-key 403 fired on key PRESENCE. The General tab re-posts
general_site_url on every save, so a settings.edit-only role (the office
manager this PR enables) got 403'd on every General save even when the URL
was unchanged. Restore change-detection: compare the incoming value against
the stored one and 403 only on an actual change; unchanged protected keys are
dropped so the rest of the save proceeds. Only /general is affected.
- Commit the self-amplification guard test (was run locally, never staged):
adminRolesGuards.test.js — non-super can't grant perms it lacks, can't edit
its own role, can't escalate another role; super_admin bypasses;
team_photographer name reserved.
- Add adminSettingsProtectedKeys.test.js pinning the change-detection: an
unchanged general_site_url saves, an actual change 403s, super_admin changes it.
* 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 (nothing did before, which is
why this went unnoticed for so long)
- 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.
The engine-selection tests resolve knexfile in a child process with a clean
cwd — dotenv.config() would otherwise let a developer's backend/.env decide
the answer instead of the knexfile defaults under test. Fake credentials in
the describeEngine tests are built at runtime rather than written inline, so
secret scanners don't flag a literal after `password:`.
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 from the previous commit 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.
Nothing moves until the operator decides.
- once Postgres holds the data, the next restart switches over on its own.
- an explicit DATABASE_CLIENT is always honoured.
The check is 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 and strand the operator on an empty install.
wait-for-db.sh resolves the engine and exports DATABASE_CLIENT before the
migration step, so the runner and the server always agree. Manual migration
runs (no entrypoint, no exported client) now refuse rather than build a schema
in the wrong database.
Adds scripts/migrate-sqlite-to-postgres.js for moving the data across. It
reuses the .picpeak export/import services rather than hand-rolling a
cross-engine copy — they already handle FK suspension, JSON columns and
Postgres sequence resync. Two things had to be added 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. Postgres rejects both outright
("date/time field value out of range: 1786548038763"). Coercion is driven
by the TARGET schema, never guessed from the value.
Verified end to end against a real PostgreSQL 15: a seeded SQLite install
migrated across with booleans, timestamps and foreign keys intact, and the
serial sequences correctly advanced (the next INSERT got id 2, not a
primary-key collision). Photo files on disk are never touched and the SQLite
file is left in place as a rollback.
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>
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>
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>
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>
Phase 3 (final) of #1000. The deep content now lives on the docs site (PicPeak/docs#7), making docs.picpeak.app the single source of truth and removing the in-repo copies.
README links flip to docs.picpeak.app; the roadmap table is retired in favour of GitHub Issues. Deletes docs/_to-migrate/ and the five migrated pages. docs/migration-to-org.md stays — it's repo-transitional, not docs-site content.
In-app references to the deleted files are repointed at the docs site, including the CRM disclaimer strings in en.json/de.json and the contract-editor fallback.
Closes#1000.
Clients who need smaller files no longer make the photographer re-export. Two capabilities, both off by default.
STANDARD RESOLUTION — the size a gallery hands out for every ordinary download (single, selected, download-all). Global default in Settings, overridable per gallery with the NULL=inherit tri-state. The pre-built download-all zip is built AT the standard resolution, so changing it invalidates those archives, including a fan-out to inheriting galleries.
RESOLUTION PICKER — opt-in modal letting guests choose a different size. Custom archives are built as a DB-backed job the client polls, never cached. The picker never offers a size above the standard, and Original reappears only when the admin explicitly allows it.
Resize is fit:'inside' + withoutEnlargement — aspect preserved, never upscaled — applied before the watermark, since the mark is sized relative to its input.
Three rounds of external review hardened this: job archives are bound to the requester's visibility scope and re-validated at delivery, the streamed download-all path applies the cap, queue admission is bounded, and rejected resolutions no longer inflate download stats.
Closes#858.
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, so portrait photos lost their top and bottom and 'Black Bars (No crop)' looked inert.
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.
Fixes#1015.
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), transitive via postcss
- js-yaml 4.3.0 -> 4.3.1 (GHSA-5p4m-2wfm-xmqj, quadratic CPU in !!omap resolution), direct dependency
Lockfile-only; the existing ^ ranges already permitted both fixes. Clears the two open Trivy code-scanning alerts on main.
With OIDC enabled the login page also renders a 'Sign in with <provider>' button whose accessible name matches the unanchored /Sign In/ locator, so Playwright strict mode failed every test that logs in — 7 of 13 in the local smoke suite, which is also the pre-push gate. CI never hit it because its databases seed without OIDC config.
Anchors the regex to the full accessible name in all six call sites.
Phase 3 validated a stored ID token hint against the currently configured issuer, but the oversize path never got that check: an ID token above the 3.9KB cookie limit was stored as the bare string 'sso', which collapsed to an undefined hint at logout and skipped validation entirely. Changing the issuer while such a session was live bounced the user to the new IdP on logout.
Stores sso.<base64url(issuer)> instead and moves all marker interpretation into buildEndSessionUrl: raw ID token -> iss/aud-validated hint, issuer-tagged marker -> round-trip without a hint, anything else -> no round-trip. Every branch fails closed.
Refs #798.
Closes#1003.
#999 centralised the attribution so branding_hide_powered_by is honoured
everywhere, but GalleryLayout kept its own inline guard. The gallery footer
therefore still flashed — it kept `!brandingSettings?.hide_powered_by`, where
undefined is falsy, so a white-labelled instance briefly showed the attribution
on first paint, on the surface a white-label customer is most likely to see.
And there were two implementations of one rule, which is the bug class #999
existed to close.
The footer appends the attribution to its copyright line inside an existing
<p>, so a straight swap would nest a <p> in a <p>. Added an inline variant
rendering a <span> that carries the leading ' | ' itself: the separator belongs
to the component, since a caller placing its own would have to repeat the
visibility guard to avoid leaving a dangling separator when the attribution is
hidden.
No extra request — GalleryView already uses usePublicSettings(), the same hook
and react-query key, so the cache is shared. The footer also picks up
common.poweredBy, so it is translated rather than hardcoded English.
Removes the now-unread hide_powered_by from GalleryLayout's prop type and the
mapping feeding it in GalleryView.
Four cases cover the variant — span not paragraph, separator present, separator
hidden with the attribution when white-labelled, hidden while loading. Each was
checked against the pre-fix shape: rendering a <p> or moving the separator out
breaks one.
Phase 1 of the README slim / docs-migration plan in #1000.
README goes from 577 to ~191 lines: hero, one Quick Start, a Documentation
index, comparison table, tech stack and a table of contents. The deep inline
prose moves into a temporary docs/_to-migrate/ staging folder (webhooks,
storage backends, first-run setup, system requirements, roadmap) so README
links keep resolving until the docs-site pages are live.
Existing docs/*.md referenced by app code are deliberately left in place —
crm-disclaimers.md (frontend TSX, i18n, a backend route and migration),
fonts.md (server.js), accounting-inbound-invoices.md (Dockerfile) and
migration-to-org.md (UpdateNotification.tsx, MigrationBanner.tsx). Moving them
is a separate, code-touching change.
Verified before merge: merges cleanly against main with no conflicts; all 14
in-repo links resolve in the merged tree; no docs file is deleted or renamed;
and the registry-move notice from #995 survives the rewrite in condensed form,
keeping 'still responds but its tags are frozen at 2026-05-27' plus the
migration-to-org.md link. The fuller symptom explanation remains in that doc,
which the README links to.
Follow-up per #1000: port docs/_to-migrate/* into docs.picpeak.app, then flip
the README links and delete the staging folder.
Co-authored-by: Luca-Timo <Luca-Timo@users.noreply.github.com>
Closes#1005.
The two ownership guards added during the #998 review were correct on merge but
untested. They are the only thing between a scoped admin and every other
admin's ORIGINAL files, since a transfer serves those over an unauthenticated
token URL.
14 cases: filterOwnedPhotoIds (own / foreign / ownerless-legacy / mixed /
non-existent / super_admin), addFiles gating on the same rule, listTransfers
scoping plus the absence of token/upload_token/download_url/upload_url from the
list payload, and getTransferOwner.
Each was checked against the pre-fix behaviour rather than only passing against
current code — reverting each guard in turn fails exactly the cases covering it:
ownership filter 3, list scoping 1, payload strip 1, guard registered late 1.
requireTransferOwnership is module-local, so its two contracts are asserted at
the source following the #596 pattern: that router.use('/:id', ...) precedes
every /:id route — ordering is the whole mechanism, and a late registration
would guard nothing while still looking present — and that missing and foreign
ids both answer 404, so the endpoint is not an existence oracle.
Tests only; no production code touched.
Closes#997.
Send original files from any event as a token-protected download link, with an
optional client-upload channel. Strictly opt-in behind a new `transfers`
feature flag, default OFF.
Migrations 170-172 (transfers, transfer_files, transfer_extra_files,
transfer_uploads, transfer_recipients, transfer_downloads, default settings and
two email templates) — all hasTable/hasColumn-guarded and idempotent, with
destructive statements confined to down().
Backend: transferService (CRUD, 256-bit download token, 6-char upload token,
cross-event ZIP streaming of originals), admin CRUD routes, and two public
token routes. transferCleanupService runs an hourly retention sweep; source-event
photos are never touched. All three routers fail closed via
requireFeatureFlag('transfers').
Review closed two ownership blockers, both the same root cause — permissions
used where ownership was needed:
- photoIds arrived from the request body and were validated only for existence,
so a scoped admin could bundle any event's originals and hand them out through
the public download token. filterOwnedPhotoIds now resolves ids to their events
and gates them through filterOwnedEventIds, on both the create and add-files
paths.
- The transfer list was unscoped and carried each row's download token, so any
admin with events.view could read another's token and fetch their originals.
The list is now scoped by created_by, the token/url fields are stripped from
the list payload, and a single router.use('/:id', requireTransferOwnership)
covers all twelve /:id routes, 404ing foreign and missing alike.
The admin photo picker filters its event list to the same rule, so the UI stops
offering picks the API would discard.
Fork-PR workflows had not been approved since the fix commits, so the PR's green
checks were stale against the pre-fix head. Verified by dispatching tests.yml
against the actual head: backend and frontend both green.
Follow-up: neither ownership guard has a regression test yet.
Co-authored-by: Luca-Timo <Luca-Timo@users.noreply.github.com>
branding_hide_powered_by only hid the attribution on the main gallery footer. It
stayed visible on the gallery password screen, client access page, Premium
layout, admin and customer login, accept-invite and CMS pages — AdminLoginPage
rendered it unconditionally with no guard at all, so the setting genuinely did
not apply there.
Routes those surfaces through one <PoweredBy /> component in components/common
that reads the public setting itself (the DynamicFavicon pattern) and renders
nothing when white-labeling is on, including while the settings are still
loading so a white-labelled instance never flashes the attribution.
Also collapses three duplicate translation keys (gallery.poweredBy,
adminLogin.poweredBy, customer.login.poweredBy) into a single common.poweredBy,
and translates pages that had 'Powered by' hardcoded in English across all 8
locales.
Fork-PR workflows were never approved so CI did not run. Verified locally
against cf243b44: tsc --noEmit clean, ESLint clean, vitest 124 passed across 24
files, and npm run build succeeds.
GalleryLayout.tsx keeps its own inline guard and is not routed through the new
component; tracked separately.
Co-authored-by: lbossuyt <lbossuyt@users.noreply.github.com>
Closes#985.
README and migration-to-org.md both claimed the old path 'is no longer served'.
It is served — ghcr.io/the-luap/picpeak/backend:latest returns a complete image,
created 2026-05-27, label version: main. The registry responds normally; it just
never receives anything new.
That inaccuracy is what generates reports like #982. Told the path is not
served, an operator runs docker compose pull, watches it succeed, runs docker
rmi and pulls again, watches that succeed too, and concludes the problem lies
somewhere other than their image path. Nothing reports an error anywhere; the
only symptom is an update notice that never resolves.
Say what actually happens — the path freezes rather than failing — and add a
self-diagnosis via docker image inspect on both paths, with the 2026-05-27 date
and the 'main' version label as the tells. MigrationBanner's wording is left
alone: 'no longer being updated' was accurate.
This is the delivery mechanism for #985. There is no in-app channel:
MigrationBanner shipped a month after the freeze, the #993 update-check notice
cannot fire on installs running their own frozen backend, and the changelog
modal that renders release notes shipped two days after the freeze. What reaches
these operators is GitHub, and the GHCR page for the retired package — which
renders this README through the images' own org.opencontainers.image.source
label, so the fix propagates to the dead path's own page automatically.
Closes#868.
A logged-in admin opening a published, password-protected gallery is let
straight in, mirroring the existing draft-visibility bypass.
Mechanism: an explicit ?admin_preview=1 intent flag AND a verified admin session
read from the httpOnly admin_token cookie (or an admin-typed Bearer) — never a
token from the URL. This retires the old ?preview=<raw-admin-JWT> scheme, which
leaked a 24h admin token into the address bar, referrers and proxy logs.
Per-request bypass only: no gallery JWT is minted, the password endpoint is
never reached so the login_attempts lockout buckets stay clean, and admin
previews are excluded from guest analytics (access_logs, download counts,
per-photo view_count, notification bells).
Review (two rounds) closed three blockers and two concerns:
- Transport: verifyGalleryAccess now resolves admin preview before any gallery
credential, and isAdminPreview reads the admin cookie first and type-checks
every candidate — so an admin Bearer no longer 403s on the type gate, and a
coexisting gallery session can no longer shadow the admin cookie.
- Reveal mode (#838) is a second consumer of isAdminPreview; its bypass is
unchanged, only the transport moves. revealMode.test.js updated off the
retired scheme and now carries a coexisting gallery Bearer.
- Admin previews no longer inflate per-photo view counts, and the internal photo
redirects preserve the flag via withPreview() so they still authorise.
- Happy path: GalleryPage renders GalleryView directly for a preview instead of
attempting the public empty-password auto-login, which 401'd against a
genuinely protected gallery and stranded the page on the skeleton.
The backend job timed out once at the 10-minute CI limit; a re-run completed in
2m02s, in line with main's ~2m10s baseline, so that was a runner flake rather
than a hang.
Relates to #985 — does NOT close it.
Adds registryMigrationRequired to the update-check payload (stable channel below
3.45.0) and an amber block in UpdateNotification explaining that the retired
registry path still responds, so `docker compose pull` appears to succeed while
serving the same frozen build.
Known limitation, established in review and merged deliberately: this cannot
reach the operators #985 describes. PicPeak is self-hosted, so the update-check
code runs inside the operator's own image — a v3.44.0 install runs v3.44.0's
backend forever, and the only external call returns release metadata, not logic.
Every build containing this predicate is >= 3.45.0, where it is false by
definition. The release-notes fallback fails too: the changelog modal shipped
2026-05-29, two days after the freeze.
Correct for any future rename, no runtime cost, but #985 stays open — the
population it describes still has no in-app channel. Viable routes are external
(retired GHCR package description, repo README, docs).
'0.0.0' is excluded from the predicate: that is getCurrentVersion's fallback for
an unreadable package.json, i.e. a broken install, not a pre-rename one.
linkDealToProject re-points a deal's quotes, contracts and events into
`projectId`. Its lineage guard vets the SOURCE events and its comment assumed
the route had vetted the destination — true only for attachDocumentToProject.
quoteService.create/update and contract crud.create/update take `projectId`
straight from the request body behind quotes.manage / contracts.manage, which
are permissions, not ownership; adminQuotes.js and adminContracts.js carry no
ownership guard at all.
The lineage guard did not cover it: it is skipped when the deal has produced no
event yet, which is the state of a newly created quote, and an unassigned
destination ADOPTS the deal's customer rather than rejecting it.
A scoped admin could therefore write into another admin's project, and on an
OWNERLESS project (created_by IS NULL — legacy rows migration 167 could not
attribute) escalate to a read: once the quote converts to an event it becomes
the project's only linked event, which is the condition ownedProjectsSubquery's
second branch grants ownership on.
Vetted at the service choke point all four callers share, ahead of both the
null-deal early return (callers write project_id before calling, and deal_uuid
is nullable) and the customer check (whose 422 vs 404 was an enumeration
oracle). 404 PROJECT_NOT_FOUND throughout. super_admin unaffected.
Closes Trivy code-scanning alerts #414-#418 on the backend image.
brace-expansion 5.0.8 -> 5.0.9 CVE-2026-69152 (high) DoS via unbounded
intermediate arrays
ip-address 10.2.0 -> 10.4.0 CVE-2026-69192 (high), CVE-2026-54272 and
CVE-2026-69198 (medium) — SSRF and
trust-boundary bypasses. Needs 10.3.1+ to
clear all three.
postcss 8.5.18 -> 8.5.23 CVE-2026-69153 (medium) information
disclosure via crafted sourceMappingURL
ip-address and brace-expansion were already in overrides but pinned below the
new fixed versions; the floors just needed raising. postcss reaches the image
through sanitize-html — the direct pin is not an import, it forces the
transitive copy to dedupe to a known version, so it moves with the bump.
Only the backend image is affected: the frontend production stage is
nginx:1.30-alpine and ships no node_modules.
Each lockfile now holds exactly one entry per package, all at or above the
fixed version, and the image installs via npm ci --omit=dev so the lockfile is
authoritative.
Closes#983.
The two cross-add counter queries added in #979 were enabled on customers.edit,
but neither endpoint checks that permission:
HoursSection -> GET /expenses/inbound/by-customer/:id needs accounting.view
CustomerCrmPanels -> GET /customers/:id/hour-entries needs customers.view
An admin holding customers.edit but not the corresponding read permission fired
a guaranteed 403 on every customer-detail render. It degraded safely — the count
stayed at its 0 default so the cross-add was never offered, which is the right
outcome for that role — so this was request noise rather than broken behaviour.
Each guard now requires both: the read permission to fetch the count, and the
write permission because there is no point offering the cross-add to someone who
cannot create the combined invoice.
No seeded role is affected: migration 123 grants accounting.view and
accounting.manage together, and customers.edit projects forward from
customers.create, which migration 090 always grants alongside customers.view.
Closes#866.
Three features, all behind the `incomingInvoices` feature flag:
1. Attach the stored supplier proof PDF to the client-invoice email when a
captured invoice is re-billed/passed through, as a SEPARATE attachment so
invoice immutability holds. Global default (off), per-customer tri-state
override, and per-file selection in a new Send dialog. A missing proof at
issue time stamps inbound_documents.proof_attach_error rather than silently
dropping, and never blocks the send. Proof filename is a configurable
template with {INVOICE} {SUPPLIER} {YEAR} {MONTH} {SEQ}/{SEQ:0Nd} tokens.
2. Re-bills & passthrough panel under CRM → Customer, grouped Open/Sent/Paid
with status derived from the linked invoice lifecycle rather than a
duplicated column.
3. Cross-add dialog rolling open hours and open re-bills into one invoice,
symmetric from both entry points. The two stay distinct, contiguous line
groups — never merged into shared line items.
Migration 169 is additive, hasColumn-guarded and idempotent.
Review (two rounds) closed two concerns:
- Storno stranding: nothing cleared inbound_documents.billed_invoice_id when a
covering invoice was cancelled, so a Storno'd re-bill showed as Open in the
new panel while every billing path filters on that column being NULL — the
supplier cost could never be re-billed. releaseRebillsForCancelledInvoice now
detaches the linkage on both invoice-cancel paths, with a regression test on
the issued-cancel path.
- Permission gating: the new controls rendered on data presence alone while
their endpoints require accounting.view / accounting.manage / customers.edit.
Now gated at both the query and render layers.
Known follow-up: two cross-add counter queries are gated on a permission their
endpoint does not check (HoursSection.tsx:174, CustomerCrmPanels.tsx:270) —
degrades safely, one line each.
Closes#969.
The cockpit's email feed rendered preview/resend/cancel/retry/send-now for every mail, consulting neither the caller's role nor their permissions, producing controls that always failed:
404 - requireOwnedQueuedEmail scopes queued mail through email_queue.event_id AND ownership of that event. CRM document mail carries no event_id; and project ownership does not imply event ownership, so a project the caller owns can hold another admin's event.
403 - preview needs events.view but the four 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. The cockpit reads canAct and combines it with email.send. A missing canAct reads as false.
Regression from the GHSA-93x4 fix in #960/#966, which added the ownership middleware.
Closes#968.
The roles-join fallback in adminAuth fabricated role_name='super_admin' on ANY database error, so a transient fault (connection reset, deadlock, statement timeout, pool exhaustion) silently granted super_admin for its duration. roleName is the sole discriminator for every ownership check, so this inverted the authorization model rather than failing the request.
Gate the fallback on isMissingRolesSchema(), moved to utils/dbErrors.js and shared with apiTokenAuth. The predicate was also tightened: knex prefixes the failing SQL to err.message and that SQL always names `roles`, so the old /roles/i gate was vacuous and a generic /does not exist/ could accept unrelated faults. Now trusts SQLSTATE 42P01/42703 on Postgres and exact driver phrasing on SQLite.
* 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
* docs(security): drop the stale ownership JSDoc left by the rebase (GHSA-wrg5)
Rebasing onto main (which had gained scopeEventsQuery from #957) 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>
* 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 '&'. 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
* 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
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* 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
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* 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.
* 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
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* 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
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* 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
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* 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>
* 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>
* 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>
* 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)
* 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>
* feat(gallery): multi-select feedback filters + sort direction controls (#889)
* fix(gallery): keep mobile sidebar open while combining feedback filters (#889)
* fix(gallery): generic sort icon when direction is uncontrolled (#889)
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(gallery): per-event toggle to hide the logo on the password page (#894)
* fix(admin): harden login_logo_visible coercion for SQLite + string booleans (#894)
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(security): close two access-control advisories (GHSA-g94x, GHSA-pv6w)
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.
* test: stub requireSuperAdmin in the backup masking mock
adminBackup now calls requireSuperAdmin() at load (GHSA-pv6w export
gate), and backupSecretMasking mocks the permissions module — add the
new function to the mock so the module loads.
* 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>
* 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): 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): 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): 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): 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.
* 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>
* ci: batch stable releases into one daily version
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)
- P1: the daily job runs on a schedule, so a fork PR can spoof the head
branch name 'release-please--branches--stable' — gh --head matches the
name only. Pin --base stable AND require isCrossRepository == false so
a fork PR can never be approved+auto-merged with the release PAT.
- P2: this scheduled job is now the ONLY automatic stable cut, so the
auto-merge-enable step no longer swallows failures (|| true); it fails
loudly and verifies autoMergeRequest is actually set. A silently
expired PAT would otherwise stop releases while the workflow stays
green. Approve stays tolerant (re-approval can return non-zero).
* ci: accept an immediately-merged release PR as success (review round 2)
gh pr merge --auto merges immediately when required checks are already
green — the normal 18:00 case, since fixes land hours earlier and CI
passes. The autoMergeRequest verify then saw null on a MERGED PR and
failed the job on the happy path. Now: MERGED = success, pending
auto-merge = success, still-open-with-no-auto-merge = real failure.
* ci: read release-PR state + auto-merge in one snapshot (review round 3)
Two separate gh pr view calls raced: a pending auto-merge completing
between them made the first read OPEN and the second read null on the
now-merged PR, failing the job on a successful release. Fetch state and
autoMergeRequest together.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
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.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(admin): expose view/download counters in the admin photos list (#895 follow-up)
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.
* 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>
* fix(admin): serve videos with their real MIME type in the admin photo view (#908)
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.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(analytics): make per-photo view/download counters actually count (#895)
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>
The 3.97.0-beta.0 release PR (#899) failed its backend Tests job on
slideshowPublic.test.js: bootCrmDb's full migration chain crossed the
suite's explicit 30s beforeAll timeout argument on a slow runner. #860
raised the config default and the jest.setTimeout pins to 120s, but
hook-ARGUMENT pins override the config default and were left behind —
same time-bomb, different syntax.
Every beforeAll that boots the migration chain and pinned 30s/60s is
raised to 120000 (16 suites). Untouched on purpose: the three suites
whose pinned hooks don't run migrations (webhookDelivery,
imageProcessor.storage, storageBackend) and publicQuotes' 30s pin on
the rate-limit lockout test — neither grows with the migration chain.
No test logic changed.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(feedback): let guests remove their star rating (#884)
Clicking your current rating again clears it. rating: 0 is the wire
contract: the validator now accepts 0, and the service deletes the
guest's rating row (instead of storing a 0 that would drag the photo
average down) and recalculates photo stats. The lightbox stars send 0
on a same-star click; PhotoRating already did, but the backend rejected
it with a 400 until now.
* fix(feedback): harden the rating-clear path (#884 review round)
External review follow-ups: numerically normalize the clear sentinel so
a numeric-string "0" can't slip into the update/insert paths (validator
now also toInt()s), delete the full guest-scoped rating set on clear so
racy duplicate rows can't survive in the average (same defense as the
reaction path), and refresh the visible average/count after the
identity-modal submit path like the direct paths do.
* fix(feedback): round-2 review fixes for rating clear (#884)
- Clear sentinel matches only an explicit 0 / "0" — malformed input
(undefined, NaN, garbage strings) can no longer delete a rating.
- Lightbox survives the photo list shrinking while open (clearing your
rating under the Rated filter drops the photo on refetch): index is
re-anchored and the lightbox closes when the list empties, instead of
crashing on an out-of-range index.
- Story layout gets the same same-star-to-clear behavior, keyed off the
session-local my-rating map, and an explicit 0 no longer falls back to
displaying the photo average.
* fix(feedback): refresh guest-scoped caches after rating changes (#884 review round 3)
- GalleryView's onFeedbackChange now also invalidates ['my-feedback',
slug]: in guest identity mode the Rated/Liked filter membership and
chip counts come from that query (#538), so a cleared rating never
left the Rated filter until the 30s staleTime lapsed.
- PhotoRating invalidates gallery-photos + my-feedback on success: the
parent refetch fires optimistically in onMutate and could capture
pre-mutation state, with nothing refreshing after the server accepted.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The bottom info/action bar was a translucent gradient overlaying the
image, hiding the lower edge of the photo. The bar is now opaque and the
image area stops above it (measured via ResizeObserver, since the bar
height varies with flex-wrap, the optional filename line and safe-area
padding), so the photo is always fully visible.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Adds a fit-to-screen button next to the zoom controls (enabled while
zoomed) and double-click-to-reset on the image itself. Both snap the
photo back to 100% and re-centre it.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
Clicking the black bars around the image (a missed arrow click) closed
the lightbox and dropped the guest back into the grid. The lightbox now
only closes via the X button or Escape, matching what gallery guests
expect while paging through photos.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
In simple identity mode, likes and ratings submitted from the lightbox
never called onFeedbackChange, so the gallery's photo list (whose
like_count drives the Likes/Rated feedback filter chips) stayed stale
until a full page reload. Liked photos were missing from the Likes
filter; unliked photos stayed stuck in it.
The guest-identity-mode paths and the grid PhotoCard paths already call
onFeedbackChange after submitting - the simple-mode lightbox paths were
the only ones missing it. Add the call to the three missing paths:
submitLike (simple branch), submitRating (simple branch), and the
FeedbackIdentityModal onSubmit handler.
Verified locally (Docker build of main): like a photo in the lightbox
after navigating with Next/Prev, open the Likes filter - the photo now
appears immediately with no reload, and filter contents match the admin
feedback API exactly.
* fix(security): close the 5 open Trivy alerts — dep bumps + drop npm from the runtime image
Backend deps:
- postcss 8.5.10 -> 8.5.18 (CVE-2026-45623, GHSA-r28c-9q8g-f849; the pin
exists to force sanitize-html's transitive copy onto a fixed version)
- tar pin/override >=7.5.16 -> >=7.5.21, resolves 7.5.22
(GHSA-r292-9mhp-454m)
Runtime image:
- Remove the npm CLI from the final stage instead of upgrading it: npm's
bundled node_modules ship tar 7.5.19 and brace-expansion 5.0.7 (no npm
release bundles the fixed versions — checked 11.18.0 and 12.0.1), and
npm never runs in production. wait-for-db.sh now invokes the migration
runners via node directly. This ends the recurring npm-bundled-CVE
alert class; the previous 'npm install -g npm@11' line was itself a
patch for the last batch.
* fix(restore): run post-restore migrations via node — the image ships no npm
restoreService still shelled out to 'npm run migrate:safe' after a
restore; with npm removed from the runtime image that would ENOENT into
the non-fatal catch, silently leaving a restored older backup on a
schema behind the running code until the next container restart. Invoke
migrations/run-migrations-safe.js through node directly, matching
wait-for-db.sh. The PR #596 source-contract test now pins the new
invocation.
* fix(backup): make backup settings actually apply (#871)
- Wire the What-to-Backup toggles into the walker: honor
backup_include_thumbnails / backup_include_photos (opt-out,
default ON) and accept the UI's backup_include_archives spelling
for the archived gate (the engine expected _archived, so the
Archives checkbox silently never worked).
- Fix the 167.6 TB dashboard size: file_size_bytes is a bigint that
node-postgres returns as a string, and the S3 path concatenated it
onto the byte counter; coerce to Number at the source.
- Compute the real next scheduled run (cron-parser) and return it as
nextBackup; the UI read a field the API never sent and rendered a
hardcoded 'Not scheduled'. A named schedule label now beats the
stray default cron the UI always sent, which silently turned
weekly schedules into daily 03:00 runs.
- Never back up filesystem noise (.nfs* silly-renames, .DS_Store,
Thumbs.db) and honor backup_exclude_patterns in the walker
(previously rsync-only).
- Remove the compression/encryption toggles from the configuration
UI: no backend implementation exists, and collecting an encryption
passphrase while uploading plaintext is a false promise.
Closes#871
* fix(backup): close the review gaps in the settings wiring
- The UI's backup_include_archives now beats the migration-seeded
backup_include_archived: every install has the singular key seeded
true, so the alias-only-when-absent lookup made unchecking Archives
a no-op.
- rsync destinations now receive the de-selected What-to-Backup paths
and the noise filters as anchored --exclude args; previously rsync
synced the whole storage root and the walker's selection only shaped
the manifest, which then misreported what was actually transferred.
- Escape regex metacharacters in the walker's glob matcher: '.nfs*'
compiled to /^.nfs.*$/ whose leading dot matched any character, so
files like anfs-photo.jpg were silently dropped from backups.
- The Backup Coverage report now uses the same gate as the walker
(new 'skipped-by-setting' status) instead of re-implementing it
without the opt-out toggles and the archives alias.
* fix(backup): make the coverage diagnostics agree with the walker
- The coverage table shows the alias-aware flag value the gate actually
used, instead of the seeded backup_include_archived shadowed by the
UI's plural key (true next to a 'Gated off' badge).
- skipped-by-setting paths are now counted in the coverage summary
(backend, TS contract, summary card, EN/DE locales) so the totals
reconcile again when Photos or Thumbnails is unchecked.
- The form's thumbnail default now matches the backend's never-saved
fallback (include): the checkbox no longer shows 'off' while
thumbnails are being backed up, and saving an unrelated setting no
longer flips the backup scope.
* fix(backup): keep custom crons, exclude disabled rows from rsync, normalize flag display
- Saving a named schedule no longer wipes the stored custom cron: the
backend already prefers the label, so the cron field stays inert for
named schedules and is preserved for switching back to Custom. A
custom schedule now validates the 5-field expression before saving
(the backend silently fell back to daily 02:00 on a blank value).
- resolveExcludedBackupPaths now also returns rows disabled via
include_in_default, so rsync excludes them; the enabled-only loader
hid them and rsync transferred their contents anyway.
- The coverage table normalizes flag values like the walker does —
Boolean('false') displayed true beside a gated-off badge.
* fix(security): bump backend deps to close all open Trivy code-scanning alerts
- axios 1.16.0 -> 1.18.1 (GHSA-gcfj-64vw-6mp9 high + 10 medium advisories)
- sharp 0.34.3 -> 0.35.3 (GHSA-f88m-g3jw-g9cj, inherited libvips CVEs)
- mailparser 3.9.9 -> 3.9.14 (pulls linkify-it 5.0.2, CVE-2026-59887)
- brace-expansion override >=5.0.6 -> >=5.0.7 (CVE-2026-13149)
- body-parser 1.20.4 -> 1.20.6 via lockfile refresh (CVE-2026-12590)
* fix(images): migrate removed sharp failOnError option and enforce Node >=20.9
sharp 0.35 drops the deprecated failOnError constructor option, so
recoverably corrupt images would start failing upload validation and
thumbnail generation; use the failOn: 'none' equivalent instead.
sharp 0.35 also requires Node >=20.9: declare it in engines and make
picpeak-setup.sh compare the full version instead of only the major,
so native installs on Node 20.3-20.8 upgrade instead of breaking.
* fix(setup): align the Node floor with the whole dependency tree and gate native updates
html-to-text@10 needs Node >=20.19 and the glob/minimatch family excludes
Node 21, so declare engines as ^20.19.0 || >=22 and enforce the same range
in picpeak-setup.sh. Also run install_nodejs at the start of
update_native_installation so existing native installs on an old Node get
upgraded before the service is stopped, instead of restarting broken.
* fix(setup): make the update-path Node gate actually work
--update dispatches before detect_os, so install_nodejs saw an empty
PACKAGE_MANAGER, matched no install branch, and reported success on the
old runtime. Detect the OS on demand and re-verify the installed version
afterwards, failing loudly (before the service is stopped) when the
runtime still misses the engines range, e.g. a Node 21 that package
managers refuse to downgrade.
* feat(auth): OIDC logout-to-IdP — phase 3 (#798)
RP-initiated logout behind a new oidc_logout_from_idp setting: logging
out of PicPeak also ends the IdP session. The SSO callback stores the
raw ID token in an HttpOnly cookie (also the marker that the session
came in via SSO — local-password sessions never bounce to the IdP);
/logout builds the end_session URL from discovery metadata with
id_token_hint + post_logout_redirect_uri + client_id and returns it as
ssoLogoutUrl for the frontend to navigate to. Any failure (no
end_session_endpoint, IdP unreachable, feature off) degrades to the
plain local logout.
Settings surface exposes the toggle plus the computed post-logout
redirect URI to register at the IdP. Session timeouts deliberately stay
local-only.
6 integration tests over the mock IdP; live-verified against
Keycloak 26 (logout ends the Keycloak session, no confirmation prompt).
* fix(auth): harden the SSO logout marker cookie (#798 phase 3)
Codex review round 1:
- Derive the oidc_id_token cookie options from the shared cookie policy
(COOKIE_SAMESITE / COOKIE_DOMAIN / secure resolution) — hardcoded Lax
meant split-origin deployments running on SameSite=None never sent the
marker to the cross-site /logout XHR, silently disabling logout-to-IdP.
- Oversized ID tokens (>3.9KB) now store a bare 'sso' marker instead of
no cookie, so the claimed client_id-only end-session fallback actually
happens; /logout only passes the value as id_token_hint when it is a
real JWT.
- establishAdminSession clears any stale marker on every fresh login —
sessions can die without /logout (deactivation, expiry, restore), and
a surviving marker would bounce a later local-password session to the
IdP. The SSO callback re-sets the marker for its own session.
Tests: oversized-token marker + hint-less end-session URL, stale-marker
cleared on local login; helper updated for the clear+set cookie pair.
* fix(auth): validate the logout hint against the current OIDC config (#798 phase 3)
Codex review round 2: an ID token stored at login can outlive an
issuer/client config change; sending it to the newly configured IdP as
id_token_hint strands the user on the IdP's error page (providers
validate iss/aud on the hint). buildEndSessionUrl now decodes the hint
(no verification — routing only): different issuer → skip the round-trip
entirely (the session belongs to another IdP); same issuer but changed
client → keep the round-trip, drop the unusable hint. Two tests pin both
paths.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(gallery): block password form in Instagram in-app browser (#654)
Field reports show gallery password login still failing inside
Instagram's IAB after the #656 input-attribute/trim defenses. Three
changes:
- Replace the advisory amber banner with a red blocking state: the
password form is hidden in the Instagram IAB and replaced with
platform-specific "open in external browser" instructions plus a
copy-link button (clipboard API with execCommand fallback). A
"try anyway" link restores the form as an escape hatch.
- Stop masking non-password failures as "incorrect password": a request
that never got a response (offline, webview killed it) now reports a
connection error, and a reCAPTCHA 400 reports a verification failure —
both previously fell through to the wrong-password message and sent
guests chasing the wrong cause.
- Strip invisible Unicode (zero-width chars, word joiner, BOM, soft
hyphen) from the submitted password in addition to trimming — these
ride along when the password is copy-pasted out of a chat app and fail
byte-exact bcrypt compare server-side.
* fix(gallery): retry login with typed password + honor execCommand result (#654)
Codex review round 1:
- Stored passwords can legitimately contain the invisible code points the
sanitizer strips (e.g. ZWJ emoji sequences) — creation paths don't
normalize. On a 401 where the sanitized form differs from the typed
(trimmed) input, retry once with the typed value. Skipped when a
reCAPTCHA token is in play (single-use).
- document.execCommand('copy') signals failure via its return value, not
by throwing — only show "Link copied" when it returns true.
* fix(gallery): move invisible-char password fallback server-side (#654)
Codex review round 2: the client-side retry either burned the single-use
reCAPTCHA token (making exotic-but-valid passwords impossible to enter
with reCAPTCHA on) or burned failed-attempt lockout quota on every
rescued login. Doing the fallback as a second bcrypt compare inside the
same gallery/verify request eliminates both: exact bytes are compared
first (stored passwords containing e.g. ZWJ emoji keep working), the
sanitized form only on mismatch, and trackFailedAttempt only fires when
both fail. Frontend goes back to plain trim-on-submit; the client-side
sanitizer util and retry are removed. 7 integration tests pin the
contract.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The 3.94.0-beta.0 release PR (#859) failed its backend job on
workflowEngine.test.js: bootCrmDb() runs every core migration in
beforeAll, and with migrations 163-165 merged the setup crossed the
suite's jest.setTimeout(30000) on CI runners — the log shows migration
099 still seeding after the hook timed out. Same pass is green locally
and passed on #857's rebase minutes earlier: borderline-slow, not
deterministic.
- jest.config.js: testTimeout 120000 as the default, so bootCrmDb
suites without an explicit pin stop being time bombs as the chain
grows
- every suite-level jest.setTimeout below 120s raised to 120s — local
pins OVERRIDE the config default, so the 30s/60s ones would keep
flaking regardless of the global bump
No test logic changed anywhere.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
The audit #485 called for: on SQLite (native installs), timestamp
columns written with a raw `new Date()` through knex store epoch-ms
numbers; Postgres returns ISO strings. Frontend code written against
Postgres calls parseISO() on them — parseISO(number) throws and crashes
the page. #485 fixed admin Users and listed api tokens / photos /
activity as out-of-scope follow-ups.
Verified crash on main: Timeline gallery layout parseISO(uploaded_at)
against photos written by the archive-RESTORE path (raw Date). Other
raw-write surfaces (api_tokens last_used_at/revoked_at, email_queue)
degrade rather than crash but violate the ISO contract.
- extract toIso() from adminUsers.js into utils/dateNormalize.js
(contract unchanged — the 10 existing #485 tests still pin it)
- write-side: archive-restore uploaded_at, api-token last_used_at /
revoked_at, email_queue created_at/sent_at now write ISO strings
- read-side (heals existing corrupted rows): gallery /photos normalizes
uploaded_at/captured_at; api-tokens list normalizes all four
timestamp fields
- frontend defence-in-depth: Timeline layout parses uploaded_at
tolerantly (typeof guard) for stale caches / old backends
- 2 regression tests seed literal epoch numbers and assert the API
serves ISO strings
activity_logs turned out safe (created_at comes from the DB default,
not a raw Date) — left untouched.
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(gallery): reveal mode — hide gallery from guests until reveal (#838)
Guests can upload during the event but see no photos until the host
reveals the gallery, manually ("Reveal now") or at a scheduled time.
- migration 165: events.reveal_mode / reveal_at / revealed_at. Effective
visibility is computed at REQUEST time (reveal_at <= now opens the
gate exactly on schedule); the minutely scheduler only stamps
revealed_at durably and emits a gallery.revealed workflow trigger
- server-side enforcement in gallery.js: /photos returns the event
shell with photos: [] + hidden_until_reveal for plain guests;
image/download/stats endpoints 403 with GALLERY_HIDDEN (photo IDs are
sequential — listing-only gating would be probeable); feedback-summary
gated too. Slideshow tokens (surprise beamer), client access and the
admin preview bypass; the guest upload route stays open
- admin: reveal toggle + optional scheduled datetime next to the guest
upload settings, status line and "Reveal now" button on the overview;
re-enabling the toggle clears revealed_at so a gallery can re-hide
- guest UI: upload-only view (hero, friendly message, scheduled time,
upload button) for every layout; i18n for all 8 locales
- timestamps written as ISO strings — the SQLite driver stringifies raw
Date objects into garbage; ISO round-trips on both engines
- 14 integration tests over minted gallery/slideshow/client/admin tokens
* fix(gallery): reveal/re-arm semantics + upload button i18n key (#838)
- "Reveal now" also clears a pending reveal_at: the schedule is
consumed, so the full-form admin save can't accidentally re-hide a
revealed gallery with a stale future date
- setting a FUTURE reveal_at on a revealed gallery re-arms hiding —
the one intentional way to re-hide without double-toggling the mode
- guest upload button uses the existing upload.uploadPhotos key
(gallery.uploadPhotos never existed; the button showed EN everywhere)
* fix(gallery): close reveal bypasses from review round 1 (#838)
- the hero-derivative route and the secure-images token-mint +
secure-download routes are now reveal-gated: hero serves a 1920px
derivative of ANY sequential photo id and secure tokens fetch
originals — both were open bypasses while hidden. blockHiddenGallery
moved to utils/revealMode.js and shared
- customer-portal tokens (via:'customer', no accessLevel) now bypass
reveal mode — they are the host/customer, not a guest, and were
getting the upload-only view
- an open hidden guest view refetches exactly at reveal_at plus a 60s
fallback poll, so the gallery appears without a manual reload
- gallery.revealed added to the workflow editor's trigger picker so
the advertised notification hook is reachable in the UI
- migration 165 guards each column independently (partial-state safe)
* fix(gallery): reveal round 2 — remaining bypass surfaces + lifecycle edges (#838)
- legacy /api/images router reveal-gated (view, secure-token + signed-url
minting), and the signed-URL SERVE path re-checks hidden state via a
backward-compatible bypass flag in the token payload
- secure-image tokens record revealBypass at mint and are re-validated
at serve time — a re-hide kills in-flight guest tokens within the
request, while slideshow/client tokens keep working
- OG metadata and the unauthenticated /og cover fall back to the brand
logo / 404 while hidden — no hero-photo spoiler for social crawlers
- photo-feedback GET/POST reveal-gated (sequential ids were enumerable);
/my-feedback returns the empty back-compat shape (rows leak filename +
storage path)
- the reveal scheduler skips drafts — no premature stamp/notification
for unpublished galleries
- emitWorkflowEvent gains an additive dedupSuffix; both reveal emitters
pass the reveal timestamp so a re-hidden gallery's second reveal
fires workflows again instead of deduping into silence
* fix(gallery): reveal round 3 — schedule consumption + two-way client sync (#838)
- the scheduler now consumes reveal_at when stamping (matching "Reveal
now"), and re-arming via a partial API update clears a stale PAST
schedule — previously {reveal_mode:true} without reveal_at could
instantly re-open the gate through the leftover date
- /photos exposes reveal_armed so an open VISIBLE gallery keeps a 60s
poll while the mode is on — a re-hide now propagates to open clients
in both directions, not just hidden→visible
Codex round-3 claim about timestamp-without-timezone drift on non-UTC
Postgres was verified FALSE: knex's table.timestamp() creates
timestamptz on PG (confirmed via information_schema on a live install),
which stores absolute instants regardless of server TZ.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(feedback): emoji reactions on photos (#839)
Per-photo emoji reactions from a fixed curated set (❤️😂😍👏🎉),
one reaction per guest per photo — same emoji toggles off, another
switches in place. Stored as feedback_type='reaction' rows with per-guest
scoping identical to likes (guest_id when present, device hash otherwise).
- migration 164: allow_reactions toggle (default on, still gated by the
opt-in feedback_enabled master switch), photo_feedback.reaction value
column, denormalized photos.reaction_count
- emoji whitelist enforced in the route validator AND the service
(shared constants/reactions.js, mirrored in the frontend)
- per-emoji tallies + my_feedback.reaction in the photo feedback
endpoint; hidden-by-moderator reactions leave all counts
- reactions ride the existing rate limiting (like-tier), guest identity
modes, and moderation actions; long + pivot exports carry the emoji
- gallery: reaction bar in the photo feedback panel (grid lightbox);
admin: allow_reactions toggle next to likes, analytics tile,
create/duplicate event paths
- i18n for all 8 locales; 9 service-level tests
* fix(feedback): reach reactions without comments; numeric analytics totals (#839)
- the lightbox feedback-panel toggle was gated on allow_comments only —
with comments off the new reaction bar was unreachable; the gate now
opens for comments OR reactions
- the analytics summary now coerces Postgres string counts to numbers:
total_feedback concatenated instead of adding ("00006")
* fix(feedback): harden reactions from review round 1 (#839)
- per-emoji tallies are gated on show_feedback_to_guests — with sharing
off a guest sees only their own selection, no aggregate counts
- reaction toggle/switch operate on the guest-scoped row SET, so rows
duplicated by the (like-parity) check-then-insert race collapse on the
next interaction instead of counting twice
- rate-limit defaults merge UNDER the persisted settings object —
stored rows predating the reaction key otherwise dropped it to the
generic 100/h fallback
- optimistic revert uses the pre-mutation value via mutation context;
the onError closure sees the post-optimistic render, so the old
revert froze the wrong state on failed toggles
* fix(feedback): review round 2 — hide reaction_count with sharing off, admin list shows emoji (#839)
- summary.reaction_count is gated on show_feedback_to_guests like the
per-emoji map, keeping the "no aggregates while sharing is off"
promise consistent
- the admin feedback list renders the reaction emoji on reaction rows
and the type filter gains a Reactions option (7 locales; es has no
types block and falls back to EN defaults)
* fix(feedback): register reaction activity types with translated labels (#839)
photo_reaction / guest_feedback_reaction are logged by the submission
paths but were absent from the frontend activity-type union and the
admin.activities label maps — the recent-activity feed would have shown
the raw identifiers. All 8 locales.
* feat(feedback): reactions in guest CRM and the premium gallery layout (#839)
- guest CRM: per-guest reaction counts in the list aggregation and a
Reacted tab (photo grid with emoji badges) + stats card in the guest
detail modal; picks/aggregate/exports stay selection-only by design
- premium layout: its own yet-another-react-lightbox now gets a fixed
reaction-bar overlay (per-photo fetch, optimistic switch) — reactions
were otherwise unreachable in this layout since it bypasses the
shared PhotoLightbox
- allowReactions threaded through the layout feedbackOptions; guest
i18n keys for the 7 locales that carry the guests block
* fix(feedback): portal the premium reaction bar to document.body (#839)
Inside the layout tree an ancestor stacking context (framer-motion
transforms) painted the bar under yarl's body-level portal — visible
but unclickable, every tap landed on the slide image. As a direct body
child the z-index 10000 genuinely wins over yarl's 9999. Verified by
clicking through in the running app.
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* feat(auth): OIDC role mapping + login policy — phase 2 (#798)
Role mapping: configurable dot-path roles claim (Keycloak realm_access.roles,
Authentik/Pocket ID groups, Entra roles), IdP-value → PicPeak-role mapping
table validated against the roles table, re-evaluated on every SSO login with
highest-priority-wins on multiple matches. The last active super_admin is
never demoted. Optional require-mapped-role policy refuses logins whose token
maps to no role (sso_error=no_role).
Login policy: oidc_disable_local_login makes the API refuse password logins
(403 LOCAL_LOGIN_DISABLED) and the login page render SSO-only; only effective
while SSO is enabled+configured, and OIDC_BREAK_GLASS=true always re-opens
local login. Public settings expose the EFFECTIVE flag only.
Settings UI: Role-mapping card (claim path, mapping rows editor, strict
toggle) and Login-policy card with break-glass hint, EN+DE.
14 new integration tests over the mock IdP.
* fix(auth): harden phase-2 review findings (#798)
- memoize the scrypt-derived OIDC key and serve /public/settings from a
10s-TTL flag cache — the unauthenticated endpoint no longer pays a
13-key config read + blocking scryptSync per request (login route
still checks uncached)
- make the last-super-admin demotion guard atomic (FOR UPDATE on the
active super rows) — concurrent mapped callbacks could previously
both count 2 and demote both supers
- own-property lookup in role mapping: IdP values like `constructor`
now count as unmapped instead of corrupting the roles query
- SsoTab clears oidc_disable_local_login in the same save that turns
SSO off — the full-form payload otherwise hit the server-side 400
* fix(auth): guarantee break-glass reachability for SSO-only mode (#798)
- wire OIDC_BREAK_GLASS and OIDC_ENCRYPTION_KEY through the quick-start
docker-compose.yml env allowlist (production compose already passes
.env via env_file) and document both in .env.example
- refuse enabling oidc_disable_local_login unless an active
local-password super_admin exists: OIDC_BREAK_GLASS only re-opens the
password route, which OIDC-owned accounts can never use, and
settings.edit is super_admin-only — an all-OIDC instance would be
unrecoverable during an IdP outage
* fix(auth): close SSO-only lockout gaps from review round 3 (#798)
- role sync never demotes the last active LOCAL-password super_admin
(an OIDC-owned super does not count as break-glass), and
isLocalLoginDisabled() disarms itself when no such account remains —
self-healing against manual demotion/deactivation/deletion paths
- the local-super save-time check now validates the MERGED state, so
re-enabling SSO with a stored disable flag is checked too
- ALL oidc_* keys are reserved from the generic settings upserts/reads
(prefix match) — policy and mapping invariants can only go through
the validated PUT /sso
- /admin/login/mfa re-checks the policy so an mfa_pending token minted
before the flip cannot complete into a local session
---------
Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
* fix(crm): pass trx to logActivity inside transactions — audit rows were silently lost on SQLite
createContract, updateContract, createStorno and reissueInvoice called
logActivity() (contract paths also adminActor()) from inside a knex
transaction without the trx executor — the pattern db.js:648's comment
explicitly warns about. On single-connection SQLite the audit insert
waits on a second pool connection while the trx holds the only one:
a 60s acquire-timeout stall per call, then logActivity's catch swallows
the failure and the audit row is silently lost. Postgres unaffected.
Fix mirrors the one call site that already did it right
(contract_created_from_quote, conversions.js): resolve the audit actor
before the transaction opens and pass trx as logActivity's executor so
the insert rides the transaction's connection.
Verified NOT affected (logActivity outside any trx, unchanged):
cancelContract, contract_converted_to_event, contract_signed_by_customer,
contract_sent, invoice_sent/_cancelled(draft)/_released/monthly_bill.
Found by the #587 integration-test work (PR #850, which shrank the pool
acquire timeout to tolerate the stall — that workaround can be dropped
once both land).
* fix(crm): run reissueInvoice's createInvoice without a wrapping transaction (codex review of #851)
The round-1 fix passed trx to the reissue audit call — but that point
was never reached on single-connection SQLite: createInvoice internally
reads via the global connection (businessProfileService.getProfile,
getAppSetting, bank-account resolution), so the outer trx deadlocked
first and aborted the replacement AFTER the Storno had already
committed and been emailed.
createInvoice's five other callers all run it without a trx; reissue
now does the same and backlinks afterwards. Trade-off documented in
code: replacement + backlink are no longer atomic — a crash between
them leaves a visible draft without replaces_invoice_id, which beats
the guaranteed stall. New regression test drives a full cancel+reissue
on the SQLite harness and pins the invoice_reissued audit row.
* fix(crm): restore the reissue transaction by routing createInvoice's reads through trx (codex review of #851, round 2)
Round 2 was right that dropping the wrapping transaction traded the
deadlock for orphan drafts: createInvoice inserts the invoice row and
claims a sequence number BEFORE line-item validation can throw, so a
failed reissue would persist partial state after the Storno committed.
Proper fix: the transaction is back, and every read inside createInvoice
now rides it — getProfile and resolveBankAccountForCurrency gained an
optional conn param (default db, all other callers unchanged),
getAppSetting calls pass trx (crm_invoice_round_total + the
resolveNetDays default the regression test flushed out), and the
invoice_created audit uses the trx executor. The reissue regression test
now proves a full cancel+reissue commits atomically on single-connection
SQLite.
* test(crm): mint-path integration tests — quote send, invoice storno, contract countersign (#587)
End-to-end through the real HTTP → route → service → DB → email-queue →
file pipeline on full-migration SQLite (helpers/crmDb), real pdfkit/
pdf-lib rendering, no mock-fs, no network. 7 tests.
Deviations from the issue spec — the tests pin the code's real behavior:
- Storno route is POST /:id/cancel (not /:id/storno), responds 200 with
{ cancelled, stornoId } (not 201).
- Quote re-send rejects with 409 (not 400).
- Contract statuses are signed_by_customer → fully_signed; the hash
columns are pdf_sha256 / signed_pdf_sha256 (no integrity_hash) — the
test verifies the stored sha256 against the file on disk.
- Business-doc PDFs persist under process.cwd()/storage/business-docs,
not STORAGE_PATH — isolated via chdir into the temp dir.
Two documented, test-scoped harness workarounds: shrunk pool acquire
timeout (guards against the pre-existing logActivity-inside-transaction
deadlock in createContract/createStorno on single-connection SQLite —
worth its own fix) and Date→ISO binding normalization (node-sqlite3's
cross-realm Date detection under jest's vm sandbox).
Assisted-by: task agent (worktree)
* test(crm): pin sendStorno side effects + real customer-sign flow (codex review of #850)
- Storno test now asserts the delivery leg cancelInvoice deliberately
swallows on failure: storno status 'sent', PDF on disk, storno_issued
email queued to the customer — a broken render/persist/queue no
longer stays green.
- Contract seed goes through sendContract's token + a real
recordCustomerSignature instead of a direct status UPDATE, so
countersign exercises the signature-layering path; the test now also
pins that the customer's signature asset survives countersigning.
* test(crm): prove both signature stamps are embedded in the countersigned PDF (codex review of #850, round 2)
Path/hash assertions alone stay green if countersign stamps the admin
onto the unsigned base PDF. New pdf-lib helper counts embedded image
XObjects per page of the final document and asserts the signature page
carries at least two — customer stamp AND admin stamp.
* feat(notifications): surface guest activity in the admin bell (#746)
Favorites already reached activity_logs (feedbackService), but gallery
opens and downloads only landed in access_logs — invisible in the
notification bell. Now:
- gallery_opened on the guest photo-list route, debounced in-memory to
one notification per event per 6h (the endpoint fires per page load;
per-hit notifications would spam the bell). Slideshow traffic stays
excluded, matching the analytics exclusion.
- gallery_downloaded on all four download paths (streamed + pre-zipped +
presigned download-all, download-selected) with scope metadata.
- Frontend: locale entries for galleryOpened/galleryDownloaded (and
photoFavorite, which previously fell through to the generic 'system
activity' line) in all 8 languages — resolved via the existing smart
camelCase fallback, no switch cases needed. Distinct bell icons per
type.
* fix(notifications): single-photo download activity + render per-type bell icons (codex review of #849)
- The per-photo Save route (GET /:slug/download/:photoId) only wrote to
access_logs — the most common download path never reached the bell.
Now emits gallery_downloaded with scope 'single', debounced to one
notification per event per hour: a guest saving 30 photos is one
signal, not thirty (exact counts stay in access_logs/analytics).
- getNotificationStyle's icon names were dead — AdminHeader hard-coded
<Bell> for every row. Added an icon map so gallery opens (Eye),
downloads (Download), favorites (Heart) and the pre-existing style
names render their intended icons.
* fix(notifications): notify after successful delivery, complete the icon map (codex review of #849, round 2)
- Single-photo notification now fires on res 'finish' with status < 400:
emitting up-front logged downloads that then 404ed/failed AND burned
the 1h debounce window against the next real download.
- Icon map completed over every name getNotificationStyle returns
(grep-verified) — settings/user/mail/etc. styles render their declared
icons instead of falling back to Bell.
Deliberately NOT taken from the review: DB-backed debounce state for
multi-worker deployments. The backend's current deployment contract is
single-process (no PM2 cluster in-repo; multi-replica explicitly parked
in #799 — chunked-upload/session state is process-local for the same
reason). Worst case under a future multi-worker setup is N notifications
per window, which degrades, not breaks; a shared-store debounce belongs
to the #799 phase-3 work.
* fix(notifications): attribute client sessions, log cached-ZIP after finish, add Trash2 icon (codex review of #849, round 3)
- gallery_opened/gallery_downloaded now carry the real actor: client
sessions (accessLevel 'client') are recorded as 'customer' instead of
being mislabeled 'guest' — #746 explicitly covers client activity, so
they are attributed, not excluded.
- Cached-ZIP streaming path logs on res 'finish' (< 400) like the
single-photo path — piping is not delivery. The presigned-redirect
and on-the-fly-archiver paths keep their existing timing (redirect
handoff / post-finalize).
- Trash2 added to the icon map (customer_erased, bulk_delete_completed
no longer fall back to Bell — the grep that built the map missed the
digit in the name).
* fix(notifications): dashboard formatting, portal dedup, actor-aware wording, archiver finish-hooks (codex review of #849, confirmation round)
- activity_logs feed TWO surfaces: the dashboard's Recent Activity used
admin.activities.<type> keys that didn't exist, rendering raw
identifiers — added gallery_opened/gallery_downloaded entries in all
8 locales.
- Customer-portal opens already log customer_event_access at the
access-token mint; the ensuing /photos call no longer double-notifies
(client sessions surface via downloads only).
- gallery_downloaded formatting is actor-aware: customer sessions render
'Customer downloaded…' (new galleryDownloadedCustomer key ×8) instead
of 'A guest…'.
- Both on-the-fly ZIP paths (download-all fallback + download-selected)
notify on res 'finish' < 400 — archive.finalize() ends Archiver's
input, not the HTTP transfer.
* fix(notifications): key customer dedup/attribution on portal provenance, neutral favorite wording (codex review of #849, final round)
The previous dedup was inverted: portal-minted tokens carry
via:'customer' but NO accessLevel (they run as guest), while PIN-client
logins carry accessLevel:'client' and log nothing else. So PIN clients'
only open signal was suppressed while portal opens still double-
notified and portal downloads read as guest activity.
verifyGalleryAccess now surfaces req.viaCustomer; gallery_opened dedups
on THAT (portal only), and galleryActor treats via-customer OR
accessLevel-client as 'customer'. photoFavorite wording is actor-neutral
across all 8 locales — feedbackService logs favorites without an actor,
so claiming 'a guest' was wrong for customer favorites.
* feat(slideshow): guest-scannable share-link QR overlay (#837)
- Global settings (Settings → Slideshow): slideshow_qr_enabled/position/
opacity/size — same option shape and cascade as the watermark.
- Per-event tri-state show_qr (migration 163): NULL inherits the global,
true/false force on/off; editable in the per-event slideshow card.
- State endpoint ships the QR as a PNG data URI (cached per share URL —
the 3s projector poll never re-encodes), so the kiosk needs no QR lib
and no extra authenticated request.
- Kiosk renders the QR in a white padded corner box so it stays
scannable on any photo.
- i18n: en + de (the slideshow namespace has no other locales yet).
* fix(slideshow): persist per-event QR override, show QR on empty shows, bound the QR cache (codex review of #848)
- OverviewTab never passed event.show_qr into the settings card (and the
Event type lacked the field), so a stored true/false override always
displayed as 'inherit' and the next save silently reset it to NULL.
- The QR overlay was nested inside the photos.length > 0 branch — an
empty or category-filtered live gallery showed only 'Waiting for
photos', exactly when 'scan to add the first photos' matters most.
Now rendered for any running show.
- slideshowQrCache: insertion-order eviction at 50 entries — rotated
tokens and past events no longer accumulate base64 PNGs forever.
* fix(slideshow): derive the QR origin from the kiosk request when the base is loopback (codex review of #848, round 2)
With the compose-default FRONTEND_URL=http://localhost:3000 (or no base
configured) the overlay QR sent scanning phones to their own localhost.
The state poll comes from the kiosk browser itself, so its Host header +
protocol (trust proxy is configured) are exactly the public origin
guests can reach — used whenever the configured base is missing or
loopback. Mirrors the ?origin= fallback #847 uses for the admin-side
QR downloads.
* fix(slideshow): kiosk passes its origin for the QR fallback (codex review of #848, round 3)
req.get('host') is not the browser origin behind the standard proxies —
frontend/nginx.conf forwards $host with the port stripped, so a compose
LAN deployment on :3000 encoded port 80. The kiosk now sends
window.location.origin with the session/state calls (validated
server-side, same pattern as #847's admin downloads); the Host-derived
origin remains as second fallback.
* fix(slideshow): reject loopback kiosk origins, throttle QR regeneration per event (codex review of #848, confirmation round)
- A loopback window.location.origin from the kiosk is no more
guest-reachable than the loopback base it would replace — rejected;
when no reachable URL remains the overlay is suppressed entirely (no
QR beats a QR that sends phones to their own localhost). New test
pins the suppression.
- The QR cache is keyed by event id with a 60s regeneration throttle:
the origin is caller-influenced when the base is loopback, so
URL-keyed caching let a slideshow-link holder force a fresh
QRCode.toDataURL per request via unique origins — a cheap CPU
exhaustion path. Encode rate is now bounded per event regardless of
input. QR margin also raised to the 4-module spec quiet zone,
matching #847.
* fix(slideshow): never serve a mismatched cached QR + single-flight encoding (codex review of #848, final round)
- A slideshow-token holder could poison the projector's QR: an
attacker-origin entry cached per event was served to the legitimate
kiosk for the rest of the throttle window. A cached artifact is now
only served when its URL matches the request; mismatches inside the
window suppress the overlay briefly instead of showing foreign
content.
- Cold-cache stampede closed: concurrent polls share one in-flight
encode promise instead of each scheduling a 512px render.
Rejected from the same round (false positive, verified empirically):
the loopback regex claim — /^https?:\/\/(localhost|127\.)/ matches
'http://localhost:3000' and '127.0.0.1:port' just fine (no trailing
slash required), and the suppression test runs green.
* feat(events): gallery QR code + printable table-card/poster PDFs (#836)
- GET /api/admin/events/:id/qr — share-link QR as PNG (128-2048px) or
SVG, inline or attachment; adminAuth + events.view + ownership.
- GET /api/admin/events/:id/qr-print — pdfkit-rendered A6 table card /
A4 poster with event name, QR, localized caption (8 locales; Cyrillic
falls back to English — built-in Helvetica has no Cyrillic glyphs) and
the share URL as footer.
- Event detail: QR section in ShareLinkCard with live preview (blob
fetch — Bearer auth) and PNG/SVG/table-card/poster downloads; print
language follows the admin UI language. i18n keys in all 8 locales.
- qrcode + pdfkit were already dependencies (MFA / CRM PDFs).
* fix(events): QR origin fallback, Unicode PDF font, bounded layout, stale-preview guard (codex review of #847)
- QR URLs: prefer the configured public base, but fall back to the admin
browser's origin (passed as ?origin=, validated) when the base is
missing or localhost — mirrors buildShareLinkUrl so the QR encodes the
same URL the card displays instead of an unusable localhost target.
- PDFs render with the bundled IBM Plex Sans TTFs (Latin+Cyrillic+Greek)
instead of WinAnsi-only Helvetica: Cyrillic event names no longer
silently disappear, and the caption's English-fallback hack is gone.
- Fixed vertical layout: title gets a bounded two-line ellipsis region
and all positions derive from constants, so long event names can't
push the QR/caption over the footer; URL footer bounded too.
- ShareLinkCard preview: stale-response guard — a late blob response
after unmount/event-switch is revoked instead of leaking and
overwriting the newer event's QR.
* fix(events): bundle complete IBM Plex Sans for QR PDFs + IPv6 loopback fallback (codex review of #847, round 2)
Round 2 caught that the pre-existing assets/fonts/IBM-Plex-Sans/ files
are 270-glyph Latin SUBSETS — my round-1 font swap didn't actually fix
Cyrillic titles and regressed the ru caption. Now bundling the complete
IBM Plex Sans 400/700 TTFs (1019 glyphs, Latin+Cyrillic+Greek — cmap
verified via fontkit, rendering verified on a generated PDF) under
assets/fonts/IBM-Plex-Sans-Full/ with the OFL license alongside.
~400 KB total; source: IBM/plex release zip @ibm/plex-sans@1.1.0.
Also: LOCAL_BASE_RE now recognizes IPv6 loopback ([::1]) so a
FRONTEND_URL of http://[::1]:3000 falls back to the browser origin like
the frontend's own URL logic does.
Note for a follow-up: the CRM invoice/quote PDFs use the same Latin-only
subsets and share the Cyrillic gap.
* fix(events): responsive QR card that survives preview failures (codex review of #847, round 3)
- The QR section keys off share-link availability instead of a loaded
preview: a transient failure of the preview request no longer hides
every download button until reload; a placeholder tile renders in
place of the image.
- Preview + actions stack on phone widths and the button grid drops to
one column below sm, so 'Tischkarte (A6)'-length labels don't
overflow.
* fix(events): QR encodes the stored share_link + spec quiet zone (codex review of #847, confirmation round)
- The QR target is now the STORED share_link — exactly what the card
displays and the admin copies. Rebuilding from current slug/token/
short-URL setting could diverge for legacy absolute links or events
created under a different short-URL setting; a printed QR encoding a
different URL than the card is a permanent mistake. Rebuild remains
only as fallback when no share_link is stored.
- QR margin back to the library's 4-module default for all generated
assets — the spec's quiet zone; margin 2 risks scan failures when the
printout sits against colored surroundings.
* fix(events): bare share_link tokens resolve as /gallery/<token> in QR URLs (codex review of #847, final round)
Quote-/contract-converted events persist share_link as the raw token —
the frontend's buildShareLinkUrl prefixes those with /gallery/, but the
QR path normalization only added a leading slash, encoding
<origin>/<token> into every image/PDF for such events. Now mirrors the
frontend exactly.
* test(events): 30s timeout for the print-PDF cases (CI fix)
The poster PDF now embeds the full IBM Plex Sans TTFs (~200 KB each);
font parsing + subsetting exceeds jest's 5s default on slower CI
runners — the suite went red on exactly that test after the font
commit.
* fix(file-watcher): bound concurrent photo processing
chokidar fires 'add' once per file — with no ignoreInitial option the
boot scan fires it for every existing file, and a bulk drop into the
watch folder fires it for every new one at once. Each handler runs DB
lookups plus (for new files) a full sharp pipeline; sharp.concurrency(2)
only caps libvips threads WITHIN one operation, not the number of
parallel pipelines, so unbounded handlers can OOM small hosts.
Gate both 'add' and 'unlink' through a shared p-limit
(FILE_WATCHER_CONCURRENCY, default 2, floor 1) — mass deletes otherwise
burst DB work and ZIP-cache invalidation the same way. p-limit is pinned
to ^3.1.0, the last CommonJS release.
Adapted from the filpgame fork (426ca491) — thanks @filpgame; extended
to cover 'unlink', documented in .env.example, plus a lock-in test for
the existing Sharp cache/concurrency caps this bound relies on.
* chore(compose): pass FILE_WATCHER_CONCURRENCY into the backend container (codex review of #846)
The backend service uses an explicit environment list (no env_file), so
the documented override never reached the container in the default
compose deployments. Added to both compose files + root .env.example.
* fix(uploads): keep videos when thumbnail generation fails
processUploadedVideo() (ffmpeg probe + thumbnail) was unguarded in both
pipeline paths, while the image branch next to each already survives its
thumbnail failures:
- processUploadedPhotos (sync): the throw failed the whole upload — the
video was lost.
- processPhoto (async worker, the path real uploads take): the throw
marked the row 'failed', and the guest gallery only lists 'complete' —
the video became permanently invisible despite being fully uploaded.
Both call sites now fall back to extractVideoMetadata() alone and keep
the video without a preview; if even the probe fails, the video is kept
with no metadata. Idea from the munin92 fork (2026-07-02), reimplemented
for both paths + regression test.
* fix(uploads): placeholder thumbnail for rescued videos (codex review of #845)
A completed video with a NULL thumbnail made the gallery grid fetch the
ORIGINAL video file as an <img> blob (thumbnail_url || url) — a
potentially multi-GB download for a broken tile. Both fallback paths now
generate the existing sharp-rendered play-button placeholder
(generateVideoPlaceholder — ffmpeg-free), so rescued videos get a real
tile. Test asserts the placeholder key lands in thumbnail_path.
* fix(security): read the password-complexity key the settings UI writes
The settings UI saves the admin's complexity choice as
security_password_complexity (useSettingsState.ts prefixes security_ to
password_complexity), but getPasswordComplexitySettings() queried
security_password_complexity_level — written by nothing — so the setting
was silently ignored and password validation always used the 'moderate'
default. Spotted in the filpgame fork (their main, 2026-07-14).
* fix(security): accept the Postgres json-column shape of the complexity value (codex review of #843)
On SQLite the TEXT column returns the JSON-stringified value
('"very_strong"'), but on Postgres (production default) setting_value
is a json column and arrives already decoded ('very_strong') — the bare
JSON.parse threw and the outer catch silently fell back to 'moderate'
again. Parse with fallback, mirroring getAppSetting's documented
pattern; test now covers both driver shapes + the empty-value default.
Follow-ups from the codex review of #834:
- .gitignore: backend/storage/ is runtime-generated (media, previews,
thumbnails, business docs) and was only partially ignored — E2E runs
left it dangling as untracked, which is how ~12 MB of artifacts nearly
landed in a commit. Ignore the whole directory (nothing under it is
tracked); replaces the narrower business-docs rule.
- backend/.dockerignore: the granular storage/* rules missed
storage/previews, so locally generated previews were copied into
production images. Exclude storage entirely — the Dockerfile creates
the needed directories itself (RUN mkdir -p, Dockerfile:96).
- fileSecurityUtils.js: remove getSafeFilename — zero callers across the
repo, and its private extension whitelist silently drifted from the
real validation paths (see #834), which is exactly the trap dead
security code sets.
- getFrontendExtensionMap now tolerates quoted keys and trailing comments
and throws on any other unparsable map line, so future syntax drift fails
loudly instead of silently dropping entries from the comparison.
- Revert the .dng/.heic/.heif addition to getSafeFilename: the helper has
no callers, so the edit was dead code. Live validation paths already
cover these formats.
- Derivative key collision: processUploadedPhotos/replacePhoto passed the
client-supplied original filename as the RAW output basename, but thumbnails/
heroes/previews are global keys — two galleries uploading IMG_0001.dng would
overwrite each other's derivative. Use the unique stored newFilename instead.
(processPhoto already used the unique photo.filename.)
- Watermark: the watermark path opens the original with sharp, which can't decode
RAW, so it fell back to the original bytes and recorded the copy as watermarked.
Skip RAW in generateForPhoto (like videos) so the watermark state stays honest
until RAW watermarking is properly supported.
- exiftool added to Dockerfile.dev so dev/native runtimes don't accept a DNG then
fail it with ENOENT.
The RAW/DNG extraction was only wired into processUploadedPhotos() (the
synchronous path), but real uploads queue to 'pending' and are handled by the
background worker → processPhoto(), which generated the thumbnail + dimensions
directly from the DNG (both fail) and then marked the photo 'complete' — success
with no thumbnail. Wire withProcessableImage() into processPhoto() (the live
path) and into photoReplacementService.replacePhoto() (replace-by-name), so all
three ingest paths extract the embedded JPEG preview for RAW.
Updates the processPhoto test's imageProcessor mock with the new
withProcessableImage dependency (pass-through for ordinary images).
The lightbox falls back to photo.url (the ORIGINAL) when preview_url is null,
which happens by default (lightbox_preview_enabled=false). For HEIC/HEIF/DNG the
original bytes aren't renderable in an <img>, so the lightbox showed a broken
image. Now force preview_url for those formats (by MIME or extension) regardless
of the toggle, so the browser always gets the generated JPEG preview. Covers DNG
too (forward-compatible with #833).
EXPERIMENTAL caveat unchanged: whether the preview actually renders still depends
on the backend decoding the source — HEVC-in-HEIC on the prod Alpine image is
unverified, DNG needs exiftool (#833). Documented on the PR.
The magic-number check in validateFileContent uses .every(), so the two
endianness entries (II + MM) could never both match — an admin DNG upload would
be rejected at content validation. Use the little-endian II magic only (Apple
ProRAW / camera DNGs); a rare big-endian DNG fails the check and is rejected,
which is safe since the embedded-preview extraction validates real content.
Two findings from the Codex review:
- validateFileType requires an ALLOWED_MEDIA_TYPES entry, which had neither
image/heic nor image/heif — so HEIC was rejected before sharp ever saw it,
despite the EXTENSION_TO_MIME additions. Added both with a single 'ftyp'
(offset 4) magic number (the check is .every, so alternatives can't be
separate entries).
- Changing the shared upload.fileRequirements string to interpolate {{formats}}
left the admin PhotoUpload caller passing only { limit }, rendering the
placeholder literally (it was also already dropping {{sizeLimit}} from #823).
The admin caller now passes formats + sizeLimit + limit, from the admin
settings it already loads.
Sharp's bundled libvips has no raw loader, so a DNG can't be thumbnailed
directly. This adds a preview-extraction step so RAW/DNG uploads get a proper
thumbnail + gallery preview while the original RAW is kept for download.
- imageProcessor: isRawFilename() + extractRawPreview() (exiftool extracts the
embedded full-res JPEG — JpgFromRaw → PreviewImage → ThumbnailImage, validated
with sharp) + withProcessableImage() which is a pass-through for ordinary
images and swaps in the extracted JPEG for RAW. Wired into ingest
(photoProcessor) and all three on-demand generators (ensureThumbnail/Hero/
Preview). generateHeroImage/generatePreviewImage gained outputBasename so
RAW-derived outputs stay named after the source.
- Dockerfile: add exiftool (confirmed present in Alpine v3.24 community).
- Format maps: dng → image/x-adobe-dng in uploadSettings.js and fileTypes.ts;
ALLOWED_MEDIA_TYPES gains a DNG entry (TIFF magic numbers) so it passes the
security file-validator.
Strictly gated by extension: nothing in this path runs for jpg/png/webp/etc, so
existing photos are unaffected. If extraction fails (corrupt RAW, no embedded
preview), the photo is marked 'failed' with a clear error — same as any
unreadable upload.
Verification boundary (please validate on a real DNG after the image rebuilds):
the exiftool extraction itself couldn't be exercised in the dev sandbox
(exiftool isn't a dev dependency and there's no DNG fixture). Unit tests cover
the gating (RAW detection + non-RAW pass-through + clean failure without
exiftool); existing processPhoto tests still pass. Known limitation: a DNG is
only accepted when the browser reports its MIME as image/x-adobe-dng (Chrome
does); browsers that send an empty type reject it client- and server-side —
a follow-up can add extension-based acceptance for the RAW set.
Companion to the HEIC/dynamic-hint PR; targets main only.
Two of the three things from #821:
- HEIC/HEIF (iPhone) can now be enabled. Sharp's bundled libvips decodes `heif`
input (verified: sharp.format.heif.input.file === true on 0.34.3 / libvips
8.17.1), so thumbnails generate. Added heic/heif to EXTENSION_TO_MIME in both
the backend (uploadSettings.js) and the frontend (fileTypes.ts) maps, which
are kept in sync. (iOS Safari usually transcodes HEIC→JPEG at file selection,
but a genuine .heic upload is now handled when it arrives.)
- The upload requirements hint no longer hardcodes "JPEG, PNG or WebP". New
extensionsToLabel() renders the actually-configured, supported formats (e.g.
"JPG, PNG, WEBP, MOV"), and upload.fileRequirements interpolates {{formats}}
across all 8 locales. Unsupported extensions are dropped from the label so it
never advertises a format the backend would reject.
DNG / camera RAW is deliberately NOT included: Sharp's libvips has no raw loader,
so a DNG would upload then fail thumbnailing (photo → 'failed', no preview).
Proper RAW support (embedded-preview extraction) is a separate PR.
Adds vitest coverage for extensionsToLabel + the HEIC mapping.
Three follow-ups from the Codex review of #823:
1. PublicSettings TypeScript interface was missing general_max_file_size_mb,
so UserPhotoUpload's access produced TS2339 under `tsc -b` (build:check). CI
didn't catch it because the pipeline runs `build` (esbuild, no typecheck),
but it's a real type gap — the #614 count field is declared, this one wasn't.
Added the optional numeric field.
2. The general-settings update endpoint validated general_max_files_per_upload
but not general_max_file_size_mb, so an out-of-range value (0, -1, huge)
could persist. publicSettings then advertised the raw value while
getMaxFileSizeMb() normalised it — the guest UI would reject files the
backend accepts. Added the same validate-and-clamp block (1..MAX_ALLOWED_FILE_SIZE_MB).
3. The update route cleared the file-count cache but not the new file-size
cache, so for up to 60s the public endpoint could advertise a new limit
while multer still enforced the old one. Now clears both under the same
uploadLimitTouched guard.
Follow-up on the merged #823 (main-only), so this targets main only.
hero_logo_visible is nullable — null means "inherit the global
branding_logo_display_hero toggle" (#756, migration 152). But the create and
update validators used `.optional()` without `{ nullable: true }`, which only
skips `undefined`; an explicit `null` still ran `.isBoolean()` and failed with
HTTP 400 "Invalid value". Saving an event with `hero_logo_visible: null` (the
inherit state the frontend sends) was rejected on v3.45.2.
- Both routes: `body('hero_logo_visible').optional({ nullable: true }).isBoolean()`,
matching the already-correct `hero_logo_size` rule next to it.
- Create handler: guard on `!= null` instead of `!== undefined` so an explicit
null stores NULL (inherit) rather than being coerced to 0/false by
formatBoolean on SQLite. The update handler already did `=== null ? null`.
Left hero_logo_position on plain `.optional()` on purpose: its column is NOT
NULL (no inherit migration) and its handler always resolves to a concrete value
via `|| brandingDefaults`, so null is genuinely invalid there — allowing it
would trade the 400 for a 500.
Adds smoke tests: PUT accepts hero_logo_visible: null and stores NULL; a
non-boolean value is still rejected.
Production installs use docker-compose.production.yml (the README's documented
path, pinned GHCR images, no dev services), but the dashboard's update
instructions emitted bare `docker compose pull` / `up -d`. Bare `docker compose`
operates on docker-compose.yml — a different, build-based stack — so a
production user who followed the steps:
- never pulled/recreated their real containers (stayed on the old version,
e.g. stuck on 3.44.0 after "updating" to 3.45.2), and
- started the dev-only mailhog service that docker-compose.yml defines
(reported restart-looping).
The backend runs inside a container and can't stat the host's compose files, but
docker-compose.production.yml passes PICPEAK_RELEASE_CHANNEL into the backend env
and docker-compose.yml does not. detectEnvironment() now derives
isProductionCompose from it, and the Docker update steps prepend
`-f docker-compose.production.yml` when set. The non-production branch keeps the
bare commands but the warning now tells users to add `-f docker-compose.production.yml`
if they installed with it.
Also gates the mailhog service in docker-compose.yml behind a `dev` compose
profile so a plain `docker compose up -d` never starts it (opt in with
`docker compose --profile dev up -d`). Nothing depends on it (SMTP_HOST comes
from .env), so gating is safe. Verified: `docker compose config` lists mailhog
only with `--profile dev`; production compose is unchanged.
Adds unit tests for the production-vs-default command generation.
The admin's Settings → General → "Max File Size (MB)" value
(general_max_file_size_mb) never applied to guest gallery uploads — the guest
route hardcoded multer's per-file cap at 50MB (gallery.js) and the guest UI
hardcoded the same 50MB client-side guard and "max 50MB" hint text. So a guest
could not upload a large video even when the admin raised the limit (reported by
mat1990dj on #613). Same class as the file-count miss fixed in #614, for size.
- uploadSettings.js: new getMaxFileSizeMb()/getMaxFileSizeBytes() reading
general_max_file_size_mb (default 50MB, cached 60s, clamped to a 10GB ceiling),
mirroring getMaxFilesPerUpload.
- gallery.js (guest upload): multer limits.fileSize now resolves from the
setting; a LIMIT_FILE_SIZE error returns an actionable "max N MB" message.
- publicSettings.js: exposes general_max_file_size_mb (default 50) so the gallery
UI can render the real limit and guard client-side before an oversized POST.
- UserPhotoUpload.tsx: reads the limit, uses it for the client-side size guard,
and passes it to the requirements hint. The "max 50MB" literal in
upload.fileRequirements is now interpolated ({{sizeLimit}}) across all 8
locales; adds upload.fileTooLarge (en/de; others fall back to en).
Scope: guest path only (the reported gap). The admin path keeps its generous
10GB cap — admins are trusted and default 50MB would otherwise regress large
admin video uploads. Format and batch-size limits already work correctly and are
untouched. Adds SQLite-backed unit tests for the new getter.
Verified end-to-end on a booted instance: admin sets 500MB → persisted → public
settings exposes 500 → guest multer sources its cap from it.
The legacy gallery router mounted at /api/events exposed create/list/update/
delete/extend guarded by adminAuth ALONE — no requirePermission, no
requireEventOwnership. adminAuth only checks the token is a valid type:'admin'
session, which every back-office role holds, down to read-only `viewer`. So any
non-super-admin account could:
- GET /api/events → every gallery's bcrypt password_hash, share_token, and
client name/email (the list handler selects * and mapEventForApi keeps
those columns),
- PUT /api/events/:id → reset any gallery's password (full takeover),
- DELETE /api/events/:id → delete any gallery,
all bypassing the per-photographer ownership isolation the canonical
/api/admin/events router enforces. Affects any instance with more than the
single super_admin.
Fix: remove the legacy router entirely (mount + require + src/routes/events.js).
It was a superseded duplicate of /api/admin/events and unused by the frontend
EXCEPT for one live route — POST /:id/extend (the "Extend expiration" UI action,
which hit /api/events/:id/extend via the api client's /api base). That route is
migrated to the canonical mount as POST /api/admin/events/:id/extend with the
same guards as every other gallery mutation (adminAuth + requirePermission
('events.edit') + requireEventOwnership), and the frontend is repointed to it.
Behaviour of the extend itself is unchanged (expires_at + reactivate).
Verified end-to-end on a booted instance: /api/events (all methods) now 404;
/api/admin/events/:id/extend returns 401 unauth, 200 for the owner, 403 for a
non-owning editor; the full login→create→extend flow works. Adds a regression
test pinning the router removal and the extend ownership check.
Implements the three restore-hardening items deferred from the #811 Codex
review (all validated against a real Postgres, see __tests__/integration/
picpeakRestorePg.test.js). Backend-only; targets main (feature, not a backport).
1. Global session cutoff (utils/sessionCutoff.js). A restore reassigns admin/
customer/event ids, so ANY pre-restore JWT can rebind to a different restored
principal. Revoking just the importing token wasn't enough. importFromPicpeak
now stamps a unix-second cutoff in app_settings after the restore commits, and
adminAuth / galleryAuth / verifyGalleryAccess / customerAuth reject any token
whose iat predates it (cached 30s → one in-memory compare on the hot path).
The operator's forced re-login mints a token past the cutoff, so it passes.
2. Role preservation across an RBAC replace (captureOperatorRole /
preserveOperatorRole). The operator's role + granted permission NAMES are
captured before the wipe; after roles/role_permissions are replaced the role
is resolved by NAME against the restored data, and re-created with its grants
if the backup omits it — so a crafted or cross-instance backup can't silently
downgrade or lock out the operator. reinjectCurrentAdmin now returns the
operator's id so the row can be re-pointed at the resolved role.
3. Postgres identity-sequence resync (resyncSequences). batchInsert writes
explicit ids without advancing the sequences, so the next natural insert into
any restored table collided on the PK. Runs AFTER commit (setval isn't
transactional) and guards every table with a column-existence check —
pg_get_serial_sequence RAISES on id-less tables like role_permissions.
No-op on SQLite.
Tests: SQLite unit tests for the cutoff and role preservation; a gated Postgres
integration suite (npm run test:pg with PICPEAK_PG_TEST_URL) covering sequence
resync, the id-less-table guard, explicit-id reinject, role re-creation, and a
full cross-instance replaceAllTables run asserting operator preservation, role
re-establishment, FK integrity, and collision-free post-restore inserts.
Stacks on #811 (shares the reinject hardening); merge after it.
The req.admin.id fix activated reinjectCurrentAdmin(); hardening its preservation
logic (found across Codex review rounds of #811):
- MFA hijack: reinject wrote back only password_hash/is_active/
must_change_password, leaving a crafted backup's two_factor_* on the
operator's row — it could strip or replace their second factor. The email-
matched row is now updated with the operator's full AUTH set (login identity,
password, and all two_factor_* columns). Relationship/audit FKs (role_id,
created_by) are deliberately NOT forced from the snapshot: on a cross-instance
restore those pre-restore ids may be absent from the backup and would dangle
the FK (SQLite rolls back at commit); the restored row keeps its own valid
values.
- Cross-instance restore rollback / FK safety: reinject matched only by email,
so a backup shipping a different admin with the default `admin` username hit
UNIQUE(username) and rolled the whole restore back; email and username could
even collide on two different rows. Reconciliation is now non-destructive:
the email-matching row is updated in place (id preserved → restored FKs like
events.created_by stay valid); any different row holding the operator's
username is RENAMED, not deleted (deletion would fire ON DELETE actions /
dangle references); only when no row has the operator's email is a fresh row
inserted, with created_by nulled and an explicit max(id)+1 id (batchInsert
left the Postgres identity sequence unadvanced, so a sequence-based insert
could collide).
- Stale session after restore: admin_users ids shift on restore, but the
operator's live JWT is bound only to decoded.id (IP logged not enforced; the
backup controls password_changed_at). The route now revokes the token (result
checked and logged) and clears the admin cookie; the client redirects to a
fresh login via a sessionInvalidated flag. Cookie clear is the unconditional
guarantee.
Adds SQLite-backed reinject regression tests (in-place login/MFA restore with id
and FK columns preserved, username-only rename, email+username on different rows,
clean insert with created_by nulled) and the frontend redirect on
sessionInvalidated.
Deferred (design decisions / pre-existing, need a Postgres test env — see PR
discussion): global "invalidate all pre-restore sessions" cutoff; preserving the
operator's ROLE semantics across an RBAC-table replace; and resyncing Postgres
identity sequences after any restore (batchInsert leaves them behind max(id) —
pre-existing, affects every restored table).
The chunked video upload stored req.body.filename unmodified and later built
the merged path as path.join(tempDir, uploadMeta.filename). path.join does not
neutralise '../', so a filename like '../../uploads/logos/evil.svg' escaped the
temp dir on merge and overwrote arbitrary files. Requires admin with
photos.upload.
Fix: path.basename() the client filename in initializeUpload() and reject
names that collapse to nothing. Adds a regression test.
node-stream-zip's extract(null, root) writes each entry to path.join(root,
entry.name) without neutralising '../', so a crafted archive entry named
'../../uploads/logos/evil.svg' escaped the target dir and overwrote arbitrary
files (logos, .env, route files → RCE on source deploys). Requires admin with
archives.restore.
Adds assertZipEntriesWithin() to utils/safePath.js — a lexical containment
check run on the entry list BEFORE extract() — and guards both extract sinks:
adminArchives.js (the reported route) and picpeakImportService.js (the sibling
.picpeak import, same sink). Adds unit tests for traversal, absolute-path, and
sibling-prefix entries.
POST /auth/gallery/share-login validated only the 128-bit share token and then
minted a full type:'gallery' access token regardless of require_password —
computing requiresPassword at the end only to echo it, never enforce it. Anyone
holding a gallery's share link could read and download every photo in a
password-protected gallery via a direct API call, no password needed.
Fix: compute requiresPassword before minting; for a password-protected gallery
return { requires_password: true } with NO token and NO cookie. The client then
goes through /gallery/verify, which does bcrypt.compare the password. The public
(no-password) auto-login path is unchanged. The frontend already falls through
to the password prompt when share-login returns no token/event.
Adds route regression test covering the bypass, the public path, and bad tokens.
adminAuth populates req.admin, not req.user, so currentAdminId was always
undefined in the /api/admin/picpeak/import handler. reinjectCurrentAdmin()
then had no account to preserve and the admin_users table was fully replaced
by the uploaded backup — a crafted .picpeak let any admin with backup.restore
take over every admin account (critical). One-line fix: pass req.admin.id.
Closes GHSA-qxfx-4493-4v8f and its duplicate GHSA-pjp6-jcrj-3cr5.
The frontend image kept shipping vulnerable OS packages (nginx 1.28.3-r1,
curl/libcurl 8.19.0, c-ares 1.34.6) despite the apk upgrade line, for two
independent reasons:
1. The runtime stage's apk upgrade layer was cached indefinitely — the
CACHEBUST build-arg CI passes (github.run_number) was only declared in
the builder stage, and ARGs don't cross stage boundaries. Both
Dockerfiles now redeclare CACHEBUST in the runtime stage and consume it
in the apk RUN, so every build re-runs the upgrade and picks up current
Alpine security updates.
2. nginx itself can never upgrade via apk on the nginx.org-based image:
the bundled nginx-module-* packages pin the exact nginx version, so
Alpine's patched 1.28.3-r4 is unreachable (verified empirically —
apk add --upgrade nginx is a silent no-op). nginx fixes must come via
the base tag, so bump to nginx:1.30-alpine (current stable, 1.30.4 on
Alpine 3.24, same nginx.org conf.d layout — drop-in).
Verified: local image build scans clean with Trivy (0 OS findings, was 21);
container serves /health, SPA fallback, and BRAND_TITLE envsubst as non-root
nginx user.
Closes code-scanning alerts 371-374, 376-392 (nginx HTTP/2 & module CVEs,
curl CVE-2026-5773/-6276 + 6 medium, c-ares CVE-2026-33630).
Two pre-existing bugs surfaced while reviewing #806 (kept separate per
scope policy — no OIDC code here):
- backup_s3_secret_key and backup_rsync_ssh_key (an SSH PRIVATE KEY)
were returned in PLAINTEXT by GET /admin/backup/config and by the
generic settings reads (GET /admin/settings and /admin/settings/:type
— which mask the recaptcha/umami/rybbit keys but not these). All
three now mask with the established bullet sentinel, and
PUT /admin/backup/config skips the sentinel on write so the edit form
round-trips without clobbering stored credentials (same pattern as
the email/WhatsApp config endpoints)
- /api/auth/admin/login/mfa was missing from the maintenance-mode
allowlist: the first login step passed, the second factor got a 503 —
any MFA-enrolled admin was locked out exactly while maintenance mode
was on
Regression tests: masking on all three read paths, sentinel round-trip
preserves stored values, real rotation still writes.
- OIDC-owned accounts can never authenticate locally: the password
login rejects auth_provider='oidc' rows outright (generic 401), and
the super-admin password reset refuses them with a clear message —
previously a reset would have minted a local password bypassing the
IdP's MFA/access policies
- /auth/session now returns a full adminUser payload (role join) and
AdminAuthContext hydrates user state from it: an SSO redirect
establishes the session without any login JSON, which left the header
identity blank and current-admin form defaults empty
- the /sso/login error path redirects absolute to the frontend base
(same split-origin reasoning as the callback)
- docker-compose.yml passes API_URL through to the backend (production
compose uses env_file and needs nothing; dev compose is gitignored)
- authSession.symmetry test mock taught the joined admin lookup
(leftJoin, prefixed columns, aliases) — the route change made the old
mock throw, which read as "table missing, trust token"
Tests: new case pins that a known-good password on an OIDC-owned row
still gets 401. 14/14 OIDC, 13/13 symmetry.
Round 1:
- bind SSO identities to (external_issuer, external_subject): OIDC only
guarantees sub uniqueness within an issuer, so a sub-only lookup let a
newly configured IdP's user inherit an old IdP's admin account on
subject collision; migration 162 gains external_issuer + composite
unique index (unmerged migration, edited in place)
- fetch UserInfo (with sub cross-check) when the ID token carries no
email — spec-compliant providers may serve email/profile claims only
there; ID-token claims win on merge
- allowlist /admin/sso/login + /callback in maintenance mode, or
SSO-only (JIT) admins are locked out exactly when they need in
- strip reserved keys (oidc_client_secret, setup_token) from BOTH
generic settings reads (GET / and GET /:type)
Round 2:
- redirect_uri prefers API_URL (the API's public origin — where the
state cookie lives); final redirects absolute to the frontend base;
login button builds its URL via buildResourceUrl — split-origin
deployments (absolute VITE_API_URL) work end to end
- PUT /sso validates the MERGED resulting state (partial update cannot
blank issuer/client while enabled=true survives; enabling requires a
derivable redirect URI)
- openid scope forced into oidc_scopes on save
- discovery-cache key includes a secret fingerprint (multi-worker
secret rotation)
- email→admin linking claims the row atomically (conditional update on
external_subject IS NULL) — concurrent first-time callbacks with the
same verified email but different subjects can't both authenticate
Tests: mock IdP gains userinfo endpoint + email-via-userinfo-only mode;
new cases pin the userinfo merge and issuer-collision non-inheritance;
redirect assertions updated for absolute URLs. 13/13.
CI exposed that getFrontendBaseUrl() returns '' without FRONTEND_URL or
the general_site_url setting (local runs were masked by backend/.env):
the flow then sent a RELATIVE redirect_uri to the IdP, which surfaced
as an opaque IdP-side error. getRedirectUri now throws OIDC_BAD_CONFIG
with an actionable message (login route maps it to sso_error=config);
the settings GET degrades to an empty redirect_uri instead of 500ing.
The test pins FRONTEND_URL explicitly so it runs identically with and
without a local .env.
Authorization-code + PKCE against a single configurable IdP via
openid-client v5, with JIT provisioning. Verified end-to-end against a
real Keycloak 26 (realm + confidential client + verified-email user):
settings → discovery test → login button → Keycloak → dashboard.
Backend:
- migration 162: admin_users.auth_provider ('local' default) +
external_subject, composite unique index
- oidcService: settings-driven config (client secret AES-256-GCM at
rest, mfaService pattern, OIDC_ENCRYPTION_KEY fallback JWT_SECRET),
cached discovery, sub-based identity binding — email linking of
existing admins only with email_verified=true; JIT behind
oidc_autoprovision with configurable default role and an unusable
random password hash
- GET /api/auth/admin/sso/login + /callback: state/nonce/PKCE verifier
cross the redirect in a 10-min signed httpOnly SameSite=Lax cookie;
the callback reuses the local login's session establishment
(completeAdminLogin split into establishAdminSession + JSON wrapper)
so SSO sessions are identical downstream; every failure lands on
/admin/login?sso_error=<key> as a translated toast
- dedicated /admin/settings/sso GET/PUT/test endpoints (secret
write-only, redacted to a set-flag; registered ABOVE the generic
/:type matcher which would shadow them); oidc_client_secret added to
the reserved keys stripped from generic settings upserts
- public settings expose only oidc_enabled + oidc_button_label for the
login page
Frontend:
- Settings → Single Sign-On (OIDC) tab: issuer/client/secret, scopes,
autoprovision + default role, button label, enable toggle, redirect
URI copy box, server-side discovery test
- login page: SSO button (custom label) when enabled; sso_error query
param surfaced as translated toasts; EN+DE i18n
Tests: 11 integration cases against an in-process mock IdP (real
discovery/JWKS/PKCE/ID-token validation) — JIT on/off, sub-vs-email
binding, unverified-email rejection, deactivated admin, missing/forged
state cookie, nonce tamper, secret encryption round-trip, disabled 404.
MFA is delegated to the IdP on the SSO path; local login stays
available as break-glass. Role-claim mapping and logout-to-IdP follow
in phase 2/3.
The desktop feedback-filter chips (All/Likes/Saved/Rated/Commented)
were nested inside the categories row conditional, and the standalone
fallback block is lg:hidden — so a gallery without photo categories
(the default) rendered no feedback filter at all on desktop, despite
the docs and a fully working filter implementation behind it.
Render the row whenever either part has content and gate only the
category scroller on categories existing. The media-count label hides
below lg when no categories exist so the mobile layout stays unchanged
(mobile keeps its own chip block). With-categories galleries render
identically to before.
Regression test pins both chip groups in the DOM with and without
categories (fails on the pre-fix component).
Split out of #801 so the public-API behavior change gets its own review:
- v1 POST /events validates event_type against the live event_types
catalog instead of the hardcoded whitelist — custom types created in
Settings → Event Types were rejected with 400. BREAKING for the
never-seeded 'family' slug, which the old whitelist silently accepted
and wrote as a dangling reference; create a matching event type to
keep using it
- new GET /api/v1/event-types (read scope) so API-token clients can
discover valid slugs; OpenAPI enum replaced accordingly
- standalone contract→event conversion no longer hardcodes
event_type: 'wedding' — it resolves via crm_default_event_type, then
the catalog catch-all, same chain as quote→event conversion
- resolveDefaultEventType moved from quoteService to eventTypeService
for shared use (no behavior change)
Keeps #801 scoped to the setup-wizard event-types feature and its
load-bearing guards. The v1 validator/discovery endpoint and the
contract-conversion default fix ship separately so the public-API
behavior change gets its own review weight.
Three review rounds on PR #801; fixes in response:
- isValidEventType: live catalog is authoritative when it has rows — a
deleted or deactivated slug no longer validates via the legacy
fallback (fallback now only serves an empty-catalog install)
- deleteEventType: refuse deleting the last (and last ACTIVE) type;
updateEventType: refuse deactivating the last active type (unknown
slugs are rejected since the validator change, so an empty active
catalog would brick event creation)
- setup window fails closed: only an explicit stored `false` opens it
(a portable-backup restore can leave the key absent) and a normal
admin login durably closes it (abandoned-wizard case)
- reserved bootstrap keys (setup_wizard_completed, setup_token) are
stripped from ALL generic settings upserts (/general, /security,
/analytics, /seo) so the marker is genuinely one-way
- wizard step: deletes ordered so the catalog can never end up empty,
and a genuinely failed system-type deletion reloads the list and
stays on the step instead of advancing past the only window in which
it can be retried
- CreateEventPage: snap the hardcoded initial 'wedding' selection to
the first active type when the catalog no longer contains it
- v1 API: new GET /event-types (read scope) so token clients can
discover valid slugs; OpenAPI enum replaced with the live-catalog
description
The catalog-backed event_type validator (#800) makes a db('event_types')
lookup before the handler runs, which consumed the first queued mock
chain and shifted the pinned db() call sequence — 5 tests failed on CI.
Stub isValidEventType to true (validation isn't this suite's subject)
and add an explicit test for the new 400-on-unknown-type path.
Fresh installs can now shape the event-type catalog during the setup
wizard — rename, delete or replace the seeded defaults while nothing
references them. On existing installs system types stay protected.
- New wizard step between features and config: edit name/URL prefix,
remove, or add types; defaults shown as recommendations
- setup_wizard_completed app setting (migration 161): seeded true when
an admin already exists, false on fresh installs; POST /api/setup/
complete (adminAuth) flips it when the wizard finishes
- deleteEventType: system types deletable only while the flag is unset;
in-use check extended to quotes; per-type reminder template
(event_reminder_<slug>) is deleted with the type
- reminder-template self-heal no longer resurrects templates for slugs
removed from the catalog
- v1 API event creation validates event_type against the live catalog
instead of a hardcoded whitelist (custom types were rejected; the
never-seeded 'family' slug is no longer silently accepted)
- contract→event conversion resolves the event type via
crm_default_event_type / resolveDefaultEventType instead of
hardcoding 'wedding' (resolveDefaultEventType moved from quoteService
to eventTypeService for reuse)
Two invoice-PDF changes from #794.
1. VAT / free-text note (Benedikt's request, placement A). A new
`crm_invoices_vat_note_text` setting (Settings → CRM → Invoices) prints a
free-text line directly under the MwSt. row on every invoice. Data-driven:
the admin types the exact wording (Austrian Kleinunternehmer § 6 Abs. 1 Z 27
UStG, German § 19, reverse-charge, …) — no jurisdiction hardcoded. The
totals-block reserve grows by the measured note height so a long note can't
push the grand total into the footer. Read in invoice/render.js, threaded
through normaliseContext, drawn in drawTotals. Empty → row omitted; quotes
unaffected.
2. Multi-page footer overlap. On a full continuation page the line-item table
filled to the bottom margin, but the "Seite X von Y" stamp was drawn at
marginBottom-12 — INSIDE that fill zone — so items overlapped the page
number. Move the stamp into the bottom margin (below the content edge),
zeroing that page's bottom margin during the write so it can't trigger
PDFKit's auto-page-break. Verified: on a full page the lowest item text is
at pdfkitY ~790 while the page number sits at ~816 — ~26pt clearance.
Tests: render the note on a single page (byte-delta proves it renders) and
paginate a long invoice with the note (2–3 pages, no stray blank page).
- 🔴 Event ownership: GET /event/:eventId and DELETE /reorder/:eventId now use
requireEventOwnership; POST /reorder (event_id in body) gets the equivalent
inline check (super_admin bypasses; others limited to owned/ownerless events).
New test covers a settings.edit-holding non-super_admin blocked (403) on all
three per-event routes.
- 🔴 Migration renumber: 158→159, 159→160 (upstream #788 already took 158);
headers + the test's require path updated.
- 🟢 Nits: stale inline "Drag the arrows" fallback → "Use the arrows" (matches
en.json; control is click-only); invalid bg-accent-dark/150 → bg-accent-dark.
Order a gallery's categories in the flow of the day instead of A–Z. Two layers,
resolved per event: per-event override > global default > name.
- migration 158: photo_categories.display_order (global default), backfilled
from the current alphabetical order so existing galleries don't reshuffle.
- migration 159: event_category_order (event_id, category_id, position) — the
per-event override; no backfill, every event starts on the default.
- utils/categoryOrder: shared resolution used by the admin event view and the
public gallery; fails safe to the global default if the table is absent.
- adminCategories: POST /reorder sets a per-event override (globals +
event-specific, interleaved); DELETE /reorder/:eventId resets; POST
/reorder-global sets the global default. Ordering endpoints + create append.
- gallery renders the resolved order.
- Settings → Photo Categories reorders the global default; an event's Categories
tab reorders that gallery (one combined list + Reset to default). Up/down
buttons — no drag-and-drop dependency.
- en/de strings.
#783 added `type=semver,pattern=v{{version}}` to the merge-job metadata,
but metadata-action silently dropped it on prereleases — the 3.84.0-beta.0
build published only :3.84.0-beta.0 + :sha, not :v3.84.0-beta.0 (verified
in the merge-backend push log + GHCR: :v3.84.0-beta.0 → 404).
Replace the v{{version}}/v{{major}} semver patterns with type=ref,event=tag,
which emits the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0) for both
stable and beta tags — exactly the string users pin (matches the GitHub
release). Applies to both backend + frontend merge metadata steps.
Takes effect on the next release build. The bare :3.84.0-beta.0 tags stay
(the {{version}} patterns are unchanged), so both forms resolve.
The Live Slideshow already covers the core of #202 (fullscreen kiosk,
live-appending new uploads, timing/transitions/watermark, per-event
opt-in via the share link). This adds the two customization dimensions
the reporter also asked for:
- **Play order** (show_order): 'chronological' (upload order, default) or
'random' — the client shuffles the initial set (Fisher-Yates) so
live-appended uploads keep working.
- **Category filter** (show_category_id): restrict the slideshow to a
single photo category (NULL = all photos, default). Enforced
server-side on the slideshow /photos access and mirrored in the
/session + /state photo_count, so the kiosk viewer can't widen the set.
Per-event enable/disable (default off) is unchanged — it's the existing
'Generate/Disable slideshow link' flow (no token = no slideshow).
- Migration 158: show_order (default 'chronological') + show_category_id.
- Admin: Play-order dropdown + category picker in the Live Slideshow card
(picker hidden for events without categories); EN + DE i18n.
- Verified: migration (SQLite + PG); live API (category filter → 3/2/5
photos + matching count; order propagates) and the running kiosk
requests exactly the filtered set; tsc clean, 136 backend tests pass.
Add picpeak/backend + picpeak/frontend on Docker Hub alongside GHCR. The
merge jobs already assemble the multi-arch manifest from the per-arch GHCR
digests via 'imagetools create'; adding Docker Hub to metadata-action's
images list + a Docker Hub login makes the same command push the manifest to
both registries (blobs copied from GHCR). No change to the build-by-digest
jobs.
Full tag parity (main, stable, latest, semver, sha). Gated on
DOCKERHUB_ENABLED (github.repository == PicPeak/picpeak) so forks stay
GHCR-only and keep building. Requires repo secrets DOCKERHUB_USERNAME and
DOCKERHUB_TOKEN.
The two release-please tracks count independently — main bumps on every
merge, stable only on promotion — so they drifted far apart (main
v3.83.x-beta while stable sat at v3.45.0 for the same code). Document
the alignment convention: a promotion pins the stable version to main's
base version via a Release-As commit (new step 5 in the cut procedure),
so stable tracks main instead of lagging.
Also records the release-engineering note that release-please.yml must
keep target-branch: stable (the missing pin cut a bogus v2.7.0 once).
Adds a subtle 'View PicPeak on GitHub' link in the admin sidebar footer
(next to the version/storage widgets), so admins can reach the repo —
star it, browse source, report an issue — from anywhere in the dashboard,
not just the setup screen.
- Centralizes the repo URL as `repoUrl` in utils/githubReleaseUrl.ts
(githubReleaseUrl now derives from it) so the org URL lives in one place.
- target=_blank + rel=noopener noreferrer; EN + DE i18n
(`admin.viewOnGithub`); dark-mode aware, matches the muted footer style.
docker/metadata-action's type=semver strips the leading 'v', so releases
published only :3.45.0 / :3.83.1-beta.0. But git tags + GitHub releases
are named v3.45.0, so anyone pinning ghcr.io/.../backend:v3.45.0 (the
obvious choice) hit 'manifest unknown' — exactly #664.
Add v-prefixed semver patterns (v{{version}}, v{{major}}.{{minor}},
v{{major}}) alongside the existing bare ones, for both backend and
frontend. Now both :v3.45.0 and :3.45.0 resolve.
Applies to future releases; the already-published v3.45.0 only has the
bare :3.45.0 tag (retagging past releases is out of scope).
The stable release-please workflow (release-please.yml, triggered on
push to stable) had no `target-branch`, so it defaulted to the repo
default branch (main) and computed the next version from main's stale
`.release-please-manifest.json` (2.6.1) — cutting a spurious **v2.7.0**
stable release (a version regression from 3.44.0) when #771 landed on
stable, and bumping main's package.json + manifest to 2.7.0.
- release-please.yml: add `target-branch: stable` so it releases from
the stable branch (3.44.0 → 3.45.0), like release-please-beta.yml
already pins `target-branch: main`.
- Restore main's version to 3.83.0-beta.0 (backend + frontend
package.json), set `.release-please-manifest.json` to 3.44.0, and drop
the bogus 2.7.0 CHANGELOG section.
The v2.7.0 tag/release is deleted separately; the real v3.45.0 stable is
cut by re-running release-please on the stable branch after this lands.
tests.yml (the backend/frontend Jest+Vitest jobs) only triggered on
main/beta, but those two jobs are required status checks on the stable
branch. A beta→stable promote PR therefore hung forever on
'Expected — Waiting for status to be reported' for backend/frontend,
while docker-build / install-smoke / schema-drift (already listing
stable) ran fine. Add stable to the push + pull_request filters so the
Tests suite runs on promote PRs too.
2026-07-08 20:29:19 +02:00
440 changed files with 37164 additions and 4344 deletions
# 📸 PicPeak - Open Source Photo Sharing for Events
> [!IMPORTANT]
> **PicPeak has moved to its own GitHub organization.**
>
> - **Docker images** are now published at `ghcr.io/picpeak/picpeak/{backend,frontend}`. The old path (`ghcr.io/the-luap/picpeak/...`) is no longer served — update your `docker-compose.yml`.
> - **Branches**: active development is now on `main` (was `beta`); the curated stable channel is now `stable` (was `main`). Existing PRs and clones auto-redirect via GitHub.
>
> See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit and full details.
[](https://buymeacoffee.com/theluap)
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
---
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Built for photographers and event organizers, it makes it simple to share beautiful, time-limited photo galleries with clients while keeping full control over your data and branding.
> **PicPeak has moved to its own GitHub organization.** Docker images are now at `ghcr.io/picpeak/picpeak/{backend,frontend}` and active development is on `main`. The old `ghcr.io/the-luap/...` path still responds but its tags are **frozen** at 2026-05-27 — if updates never arrive, check your image path first. See **[`docs/migration-to-org.md`](docs/migration-to-org.md)** for the one-line `docker-compose.yml` edit.
## Contents
- [Live Demo](#-live-demo)
- [Quick Start](#-quick-start)
- [Why PicPeak?](#-why-picpeak)
- [Features](#-features)
- [Documentation](#-documentation)
- [Comparison](#-comparison-with-alternatives)
- [Tech Stack](#️-tech-stack)
- [Contributing & Support](#-contributing)
- [License](#-license)
## 🎮 Live Demo
Try PicPeak without installing anything:
Try PicPeak without installing anything — [demo.picpeak.app](https://demo.picpeak.app) · [admin panel](https://demo.picpeak.app/admin)
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
- 📽️ **Live Slideshow** - A separate fullscreen "Diashow" link per event for projectors at live events — auto-picks-up new uploads while it runs, with transitions, a logo watermark, and image-fit/colour options ([guide](docs/live-slideshow.md))
- 🎨 **Custom Themes** - Match your brand perfectly
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
### For Clients
- 🖼️ **Beautiful Galleries** - Clean, modern interface
- 📱 **Mobile Optimized** - Swipe through photos on any device
- ⬇️ **Bulk Downloads** - Download all photos with one click
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
- 💾 **Smart Storage** - Automatic archiving of expired galleries
- ⏱️ **Hours Logging & Calendar** - Per-customer time tracking; admin calendar of events, logged hours, and pending quotes/contracts
- 🧾 **Inbound Supplier Invoices & Expenses** - Capture received invoices (upload/camera, rasterised server-side), categorise, and re-bill costs to clients
- 📊 **Tax Report & Accountant Export** - Period-scoped income/cost report with VAT breakdown; PDF/CSV plus a Treuhänder/Banana (Swiss/LI) journal export, scopable to income-only or cost-only
- 🌍 **VAT & Multi-currency** - Single VAT-code registry snapshotted onto each document; data-driven per-country rates
- ⚠️ **Verify locally** - Feature-flagged off by default. Seeded contracts, QR/IBAN and tax defaults are **examples only** — review your own legal **and tax** regulations first (see disclaimers below)
## 🚀 Quick Start
Get PicPeak running in under 5 minutes:
@@ -96,8 +54,8 @@ cd picpeak
# Copy the environment template — the defaults work out of the box.
# Machine secrets (JWT, DB, Redis) are auto-generated on first run, and the
# admin account is created in the browser (see below). Edit .env only to
# admin account is created in the browser. Edit .env only to customise
# (domain, SMTP, storage paths, …) — nothing is required.
cp .env.example .env
# Start with Docker Compose
@@ -106,288 +64,61 @@ docker compose up -d
# Access at http://localhost:3000
```
### First run — create your admin account
On first start, open **http://localhost:3000/admin** and follow the in-browser setup to create your admin account. Full details — the one-time setup token, Docker file permissions, and ARM64 notes — are in **[First-run setup](https://docs.picpeak.app/getting-started/first-login)**.
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`:
> **Updating / release channels:** set `PICPEAK_CHANNEL` (`stable` default, or `beta`) in `.env`, then `docker compose pull && docker composeup -d`. See [RELEASING.md](RELEASING.md) for the promotion cadence.
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`):
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.
## 🌟 Why PicPeak?
> 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`).
- The backend container starts as root, chowns bind-mounted host directories (`./storage`, `./data`, `./logs`) to UID 1001 (`nodejs`), then drops privileges via `su-exec` before running the app. No host-side setup needed for fresh installs.
- If you pin `user:` in a compose override (e.g. to map a specific host UID), the self-chown is skipped and you must pre-chown the host directories to that UID — see [docs.picpeak.app/deployment/docker#permissions](https://docs.picpeak.app/deployment/docker#permissions).
- **💰 No Monthly Fees** — one-time setup, unlimited galleries
- **🔒 Complete Data Control** — your photos stay on your server
- **🎨 White-Label Ready** — full branding customization
- **📱 Mobile-First Design** — beautiful on all devices
- **🌍 Multi-Language** — built-in i18n (EN, DE)
**ARM64 (aarch64) systems:** Pre-built images include native `linux/arm64`, no platform flags or emulation needed. If you're on an older image tag that's still amd64-only, see [docker-compose.amd64.override.yml](docker-compose.amd64.override.yml) for a transitional fallback.
## ✨ Features
## 🔄 Release Channels
**For photographers** — drag & drop upload, auto-expiring & password-protected galleries, automated emails, an analytics dashboard, custom themes, a public landing page, and a [Live Slideshow](https://docs.picpeak.app/features/live-slideshow) projector view that auto-picks-up new uploads during live events.
PicPeak offers two release channels for different needs. Stable promotions are cut from a known-good beta point every 4–6 weeks — see [RELEASING.md](RELEASING.md) for the maintainer's promotion criteria and cadence policy.
docker compose -f docker-compose.production.yml up -d
```
### Update Notifications
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
```bash
UPDATE_CHECK_ENABLED=false
```
> [!WARNING]
> **CRM & Accounting — examples only, verify locally.** Feature-flagged off by default. Seeded contract blocks are written by the maintainer, **not a lawyer**; QR-bills/SEPA payloads and every tax, VAT and Treuhänder/Banana figure are computed from your input and defaults and are **jurisdiction-specific guidance only**. Have your lawyer review contracts, scan a test QR with your bank's app, and verify all numbers with your accountant / Treuhänder / tax authority before customer-facing use. Read **[the CRM disclaimers](https://docs.picpeak.app/features/crm/disclaimers)** first.
## 📖 Documentation
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings reference, API docs, webhooks, archive lifecycle, branding, and everything else. Some quick links:
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings, API, branding, and more.
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](#storage-backends)
- **Email**: SMTP with customizable templates
- **Analytics**: Privacy-focused with Umami integration
## 💾 Storage Backends
PicPeak supports two storage backends for photos, thumbnails, hero images, watermarks, and archive zips. Both are configured via environment variables; no code change is required to switch.
| External media reference mode (`EXTERNAL_MEDIA_ROOT`) | ✅ (always local) | ✅ (still local — not migrated) |
### Switching to an S3-compatible backend
1. Provision a bucket and credentials. The minimum IAM policy is documented in `.env.example`.
2. Set `STORAGE_BACKEND=s3` plus `STORAGE_S3_BUCKET`, `STORAGE_S3_REGION`, `STORAGE_S3_ACCESS_KEY`, `STORAGE_S3_SECRET_KEY`. For non-AWS providers (MinIO, R2, B2, …) also set `STORAGE_S3_ENDPOINT`.
3. If you have existing local content, copy it first: `node backend/scripts/migrate-storage.js --dry-run` then `node backend/scripts/migrate-storage.js`. The script is idempotent and writes a failures CSV.
4. Restart the backend. The startup check pings the bucket and refuses to boot on misconfig.
Note: presigned-URL serving (zero-bandwidth direct downloads from S3) is intentionally **not** in v1 — every request still streams through the backend so watermarks, devtools-detection, and access logging keep working.
## 🔔 Webhooks
PicPeak POSTs event/photo lifecycle notifications to URLs you configure under **Settings → Webhooks**. Each delivery is signed `HMAC-SHA256` with a per-webhook secret in the `X-PicPeak-Signature` header so receivers can verify the request really came from your PicPeak instance.
### Event types
| Event | Fires when |
| Topic | Link |
|---|---|
| `event.created` | Gallery created (admin or API) |
| `event.published` | Draft becomes live (`is_draft: true → false`) — also fires when an event is created with `is_draft=false` |
| `event.archived` | Bulk-archive, manual archive, or auto-archive on expiry |
| `event.expired` | Expiration checker marks the gallery inactive (fires before `event.archived` in the cascade) |
| `photo.uploaded` | Admin upload, API upload, guest upload, or auto-import |
| `photo.deleted` | Single delete, bulk delete (NOT fired per-photo when an event is archived — receivers infer from `event.archived` to avoid flooding) |
- Non-`2xx` or network error → exponential backoff: `1m → 5m → 30m → 2h → 12h`, max 5 attempts
- After max attempts: status `failed`, surfaces in **Settings → Webhooks → Deliveries** with a "Replay" button
- Up to 5 deliveries in flight at once; one slow consumer can't block others (configurable via `WEBHOOK_DELIVERY_CONCURRENCY`)
- Response body truncated to 1KB before storage so chatty receivers don't bloat the audit log
The deliveries page (`/admin/webhooks/:id/deliveries`) shows every attempt with timestamp, status, HTTP code, latency, payload sent, signature, and response. Click "Send test event" to fire a synthetic delivery for any event type.
### SSRF protection
Webhook URLs are validated against the same private-IP blocklist used elsewhere in the app — loopback, private RFC1918 ranges, link-local, `.local`/`.internal` hostnames, cloud metadata endpoints. The check runs both at create time and per-delivery (DNS-rebinding mitigation).
For local development with a receiver on the same machine or docker network, set `WEBHOOK_ALLOW_PRIVATE_URLS=true`. Production deployments must leave this OFF.
## 💻 System Requirements
### Minimum Requirements
- **CPU**: 2 CPU cores
- **RAM**: **4 GB minimum** for a normal photo-upload workload — sharp/libvips
decodes the full uncompressed frame before resize, and the default two
worker loops at sharp-concurrency 2 can push peak RSS past 1.5 GB on a
batch of 20-MP+ photos. On a 2 GB VPS that's enough to OOM-kill the
backend mid-batch (surfaces as 503s on thumbnails — see [Low-memory
hosts](#low-memory-hosts) below for the recipe to run on 2 GB).
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
**Technical Notes:**
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
- Maximum upload size: **10GB per video file**
- Chunked upload support for files >100MB (resumable uploads)
- Supported formats: MP4, WebM, MOV, AVI
- Video thumbnails are automatically generated from the first few seconds
**For Nginx/Reverse Proxy:**
If using Nginx, increase the client max body size:
```nginx
client_max_body_size 10G;
proxy_read_timeout 3600;
proxy_send_timeout 3600;
```
## 🤝 Contributing
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
See our [Contributing Guide](CONTRIBUTING.md) for details.
*You still bring your own server (own hardware or a VPS) and, if you want one, a domain.
**Limited only by your server storage.
***Pixieset's "unlimited" is photos only; video is capped by plan (roughly 0–10 h depending on tier).
🧪 Beta = built but feature-flagged off by default (see [Beta Features](#-beta-features-use-at-your-own-risk)).
<sub>*You bring your own server and, optionally, a domain. **Limited only by your server storage. ***Pixieset's "unlimited" is photos only; video is capped by plan. 🧪 Beta = built but feature-flagged off by default.</sub>
## 🛡️Security
## 🏗️Tech Stack
PicPeak takes security seriously:
- 🔐 Password hashing with bcrypt
- 🎫 JWT-based authentication
- 🚦 Rate limiting on all endpoints
- 🛡️ CORS protection
- 📝 Activity logging
- 🔒 Secure file access
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security) on GitHub
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](https://docs.picpeak.app/features/storage-backends)
- **Email**: SMTP with customizable templates
- **Analytics**: Privacy-focused with Umami integration
- **External media**: point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals read-only, index quickly, and generate thumbnails on demand
## 📸 Screenshots
### 🎛️ **Admin Dashboard**
Get a complete overview of your photo galleries, analytics, and system status.
<details>
<summary>Click to see the admin dashboard, analytics, and event management</summary>
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
- **⚡ Fast Loading**: Optimized for quick photo browsing
- **🔒 Secure Access**: Password-protected galleries with expiration
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
</details>
## 🗺️ Roadmap
## 🤝 Contributing
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
We love contributions! PicPeak is built by photographers, for photographers — whether you're fixing bugs, adding features, or improving docs. See the [Contributing Guide](CONTRIBUTING.md) to get started.
### 🚧 Beta Features (Use at your own risk)
These features are currently in beta testing and may have limited functionality or stability:
| Feature | Description | Status |
|---------|-------------|--------|
| **CRM & Accounting Module** | Quotes, contracts, invoices (+ Storno), hours logging, calendar, and tax report — plus inbound supplier-invoice capture, internal expenses, and a Treuhänder/Banana (Swiss/LI) accountant-journal export. Feature-flagged off by default. Seeded contract blocks, payment terms, IBAN / QR-bill and tax defaults are **examples only** and need legal / financial / **tax** review before customer-facing use. See [docs.picpeak.app/features/crm](https://docs.picpeak.app/features/crm). | 🧪 Beta |
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
### 📋 Future Enhancements
| Feature | Description | Priority | Status |
|---------|-------------|----------|---------|
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
| **External Media Library (Reference Mode)** | Use an external folder library as a read‑only source with import and on‑demand thumbnail generation | High | ✅ Implemented |
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
Found a security issue? Please open a [security issue](https://github.com/PicPeak/picpeak/issues/new?labels=security). See [SECURITY.md](SECURITY.md) for the policy.
## ☕ Support the Project
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds the time spent on new features, bug fixes, and keeping the demo + docs running.
<img src="https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=theluap&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff" alt="Buy Me A Coffee" />
</a>
</p>
Other ways to support without spending anything: ⭐ star the repo, share it with photographer friends, file good bug reports, or open a PR.
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider [buying me a coffee](https://buymeacoffee.com/theluap) — it directly funds new features, bug fixes, and keeping the demo + docs running. You can also ⭐ star the repo, share it, file good bug reports, or open a PR.
## 🙏 Acknowledgments
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. It's developed with AI assistance, but human-tested end-to-end, security-audited, and human-reviewed for quality.
### 👥 Contributors
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
- [**@the-luap**](https://github.com/the-luap) — creator and lead maintainer. Started the project and built PicPeak's foundation and the entire gallery experience (events, galleries, uploads, sharing, download protection, templates), plus backup & restore, analytics, system health, branding/theming, and WhatsApp notifications — and the architecture every later feature builds on.
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, several typed-API refactors, and the CRM + accounting suite (quotes/contracts/invoices, hours logging, calendar, tax report, inbound supplier-invoice capture, expenses, and the Treuhänder/Banana export). Consistently raises the bar with thoughtful PRs.
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
**[@the-luap](https://github.com/the-luap)** — creator and lead maintainer
- Gallery foundation (events, uploads, sharing, download protection, templates)
- Backup & restore, analytics, branding/theming
- The architecture every later feature builds on
**[@Luca-Timo](https://github.com/Luca-Timo)**
- Native Apple Silicon multi-arch images
- CRM & accounting suite (quotes/contracts/invoices)
- Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
### 🤖 AI-Assisted Development
This project was generated with the assistance of AI technology, but has been:
- ✅ **Fully tested end-to-end** by human developers
- 🔒 **Security audited** with comprehensive security checks
- 👨💻 **Human-reviewed** for code quality and best practices
- 🧪 **Production-tested** in real-world scenarios
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
@@ -52,13 +52,19 @@ The actual mechanics, in order:
- **`.release-please-manifest.json`** — keep `stable`'s; release-please owns this file.
- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (`backend/src/middleware/`, `backend/src/utils/tokenUtils.js`).
5. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
5. **Pin the stable version to match `main` (number alignment — see Versioning).** Determine `X.Y.Z` = the `main` tip's **base** version (its `vX.Y.Z-beta.N`, dropping the `-beta.N` suffix), and add an empty commit on the release branch:
The `Release-As:` footer forces release-please to cut exactly `X.Y.Z`. Without it, release-please computes the next MINOR from the *previous stable* tag (e.g. `3.45.0` → `3.46.0`) while `main` is already at `3.84.x`, so the stable number drifts ever further behind for the same code.
6. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
6. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
7. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
7. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
8. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
8. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
9. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
## Hotfix path (backport to current stable)
@@ -83,6 +89,14 @@ PicPeak follows [Semantic Versioning](https://semver.org/) with one project-spec
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
### Stable ↔ pre-release number alignment
The two channels run **independent** release-please counters: `main` bumps on every merge (racing ahead), while `stable` only bumps on a promotion. Left to itself, `stable` computes each promotion as the next MINOR from the *previous stable tag*, so the two drift far apart — e.g. `main` at `v3.83.x-beta.0` while `stable` sat at `v3.45.0` for the **same code**, which reads as "stable is 38 versions behind" when it isn't.
To keep the numbers legible, **a promotion sets the stable version to the current `main` base version** (the `X.Y.Z` of the `main` tip's `vX.Y.Z-beta.N`, minus the suffix). Promoting a `main` at `v3.84.2-beta.0` therefore cuts stable `v3.84.2`, and the stable number tracks `main` instead of lagging. This is forced with the `Release-As:` commit in step 5 of the cut procedure — the one-time catch-up jump (e.g. `3.45.0 → 3.84.x`) is expected and happens only on the first aligned promotion.
> **Release-engineering note (2026-07):** `release-please.yml` (the stable workflow) *must* keep `target-branch: stable`. Without it, release-please defaults to the repo's default branch (`main`), reads `main`'s stale `.release-please-manifest.json`, and cuts a wrong/regressed version — this is what produced a bogus `v2.7.0` once. If a promotion ever yields an unexpected version, check that first.
## Things that don't go through this process
- **Documentation-only changes** can land on either `stable` or `main` directly (no release cut needed); release-please will pick them up on the next regular release.
@@ -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`):
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.