* fix(analytics): make per-photo view/download counters actually count (#895) (stable)
Three stacked defects behind 'per-image stats stay 0':
- photos.view_count had NO writer anywhere — the admin IMAGES table and
photo viewer display it, so it was permanently 0. It now increments
when the full-size photo or its preview tier is served, excluding the
slideshow kiosk (migration 138 design) and follow-up video Range
requests (seeks are not views). Fire-and-forget so analytics can
never fail the byte-serving path.
- Zip downloads (download-all, presigned download-all,
download-selected) never incremented per-photo download_count — only
single-photo downloads did, so zip-heavy galleries showed 0 forever.
The zip routes now bump exactly the photos that went into the archive
(the prebuilt-zip path mirrors the archive builders' category filter).
- Every admin surface used a different definition of 'downloads', which
is the reporter's 46 vs 45 vs 0: event details counted only
action='download' (no zips at all), the dashboard counted
download+download_all but silently EXCLUDED download_selected and
download_all_presigned. All queries now share one action set:
download, download_all, download_all_presigned, download_selected.
New photoEngagementCounters suite pins all of it (7 tests).
* fix(analytics): count views via an explicit lightbox beacon (#895 review round)
External review flagged that request-level view counting is wrong in
both directions: the lightbox preloads prev/next neighbours (3 fetches
per open) while a preloaded neighbour promoted by a swipe is never
re-fetched (#505 keeps the DOM node), and enhanced/maximum galleries
never hit /photo at all (bytes come from /api/secure-images).
- Views now count via POST /:slug/photo/:photoId/view, fired by the
lightbox exactly when a photo becomes the visible slide; the
serving-route increments are removed. Covers protected galleries and
the preview tier uniformly; slideshow kiosk stays excluded.
- bumpEventDownloadCounts mirrors downloadZipService._build (ALL event
photos) — the category filter mismatched the prebuilt zip's actual
contents. (That the builder ignores per-category allow_downloads is a
separate pre-existing issue.)
- Zip loops count only successfully appended entries, with a pre-append
storage stat: a lazy stream's async error bypassed the per-photo
catch and hung the whole response — pre-existing bug, now fixed.
Suite extended to 9 tests (beacon semantics, serve-does-not-count,
skipped-entry exclusion).
* fix(analytics): fire the view beacon from the premium lightbox too (#895 review round 2)
gallery-premium events use yet-another-react-lightbox inside
GalleryPremiumLayout instead of PhotoLightbox, so the layout never
counted views. yarl's on.view fires on open and on every slide change —
identical semantics to the PhotoLightbox beacon.
Also documents the accepted prebuilt-zip approximation: _build can skip
entries whose watermark step fails and still publish the archive;
counting those exactly would need a persisted zip manifest.
* perf(analytics): skip the per-entry zip preflight on S3 (#895 review round 3)
The pre-append source check exists for LocalFs's lazy createReadStream
(async error would kill the whole zip response). S3's get() awaits
GetObject and rejects inside the loop's try/catch on a missing key, so
a HEAD per entry was a redundant serial round trip — 500 extra HEADs
on a 500-photo zip.
---------
Co-authored-by: Paul Nothaft <[email protected]>
* fix(security): close the 5 open Trivy alerts — dep bumps + drop npm from the runtime image
Backend deps:
- postcss 8.5.10 -> 8.5.18 (CVE-2026-45623, GHSA-r28c-9q8g-f849; the pin
exists to force sanitize-html's transitive copy onto a fixed version)
- tar pin/override >=7.5.16 -> >=7.5.21, resolves 7.5.22
(GHSA-r292-9mhp-454m)
Runtime image:
- Remove the npm CLI from the final stage instead of upgrading it: npm's
bundled node_modules ship tar 7.5.19 and brace-expansion 5.0.7 (no npm
release bundles the fixed versions — checked 11.18.0 and 12.0.1), and
npm never runs in production. wait-for-db.sh now invokes the migration
runners via node directly. This ends the recurring npm-bundled-CVE
alert class; the previous 'npm install -g npm@11' line was itself a
patch for the last batch. (stable)
* fix(restore): run post-restore migrations via node — the image ships no npm
restoreService still shelled out to 'npm run migrate:safe' after a
restore; with npm removed from the runtime image that would ENOENT into
the non-fatal catch, silently leaving a restored older backup on a
schema behind the running code until the next container restart. Invoke
migrations/run-migrations-safe.js through node directly, matching
wait-for-db.sh. The PR #596 source-contract test now pins the new
invocation. (stable)
* fix(backup): make backup settings actually apply (#871) (stable)
- Wire the What-to-Backup toggles into the walker: honor
backup_include_thumbnails / backup_include_photos (opt-out,
default ON) and accept the UI's backup_include_archives spelling
for the archived gate (the engine expected _archived, so the
Archives checkbox silently never worked).
- Fix the 167.6 TB dashboard size: file_size_bytes is a bigint that
node-postgres returns as a string, and the S3 path concatenated it
onto the byte counter; coerce to Number at the source.
- Compute the real next scheduled run (cron-parser) and return it as
nextBackup; the UI read a field the API never sent and rendered a
hardcoded 'Not scheduled'. A named schedule label now beats the
stray default cron the UI always sent, which silently turned
weekly schedules into daily 03:00 runs.
- Never back up filesystem noise (.nfs* silly-renames, .DS_Store,
Thumbs.db) and honor backup_exclude_patterns in the walker
(previously rsync-only).
- Remove the compression/encryption toggles from the configuration
UI: no backend implementation exists, and collecting an encryption
passphrase while uploading plaintext is a false promise.
* fix(backup): close the review gaps in the settings wiring (stable)
- The UI's backup_include_archives now beats the migration-seeded
backup_include_archived: every install has the singular key seeded
true, so the alias-only-when-absent lookup made unchecking Archives
a no-op.
- rsync destinations now receive the de-selected What-to-Backup paths
and the noise filters as anchored --exclude args; previously rsync
synced the whole storage root and the walker's selection only shaped
the manifest, which then misreported what was actually transferred.
- Escape regex metacharacters in the walker's glob matcher: '.nfs*'
compiled to /^.nfs.*$/ whose leading dot matched any character, so
files like anfs-photo.jpg were silently dropped from backups.
- The Backup Coverage report now uses the same gate as the walker
(new 'skipped-by-setting' status) instead of re-implementing it
without the opt-out toggles and the archives alias.
* fix(backup): make the coverage diagnostics agree with the walker
- The coverage table shows the alias-aware flag value the gate actually
used, instead of the seeded backup_include_archived shadowed by the
UI's plural key (true next to a 'Gated off' badge).
- skipped-by-setting paths are now counted in the coverage summary
(backend, TS contract, summary card, EN/DE locales) so the totals
reconcile again when Photos or Thumbnails is unchecked.
- The form's thumbnail default now matches the backend's never-saved
fallback (include): the checkbox no longer shows 'off' while
thumbnails are being backed up, and saving an unrelated setting no
longer flips the backup scope. (stable)
* fix(backup): keep custom crons, exclude disabled rows from rsync, normalize flag display
- Saving a named schedule no longer wipes the stored custom cron: the
backend already prefers the label, so the cron field stays inert for
named schedules and is preserved for switching back to Custom. A
custom schedule now validates the 5-field expression before saving
(the backend silently fell back to daily 02:00 on a blank value).
- resolveExcludedBackupPaths now also returns rows disabled via
include_in_default, so rsync excludes them; the enabled-only loader
hid them and rsync transferred their contents anyway.
- The coverage table normalizes flag values like the walker does —
Boolean('false') displayed true beside a gated-off badge. (stable)
* fix(security): bump backend deps to close all open Trivy code-scanning alerts (stable)
- axios 1.16.0 -> 1.18.1 (GHSA-gcfj-64vw-6mp9 high + 10 medium advisories)
- sharp 0.34.3 -> 0.35.3 (GHSA-f88m-g3jw-g9cj, inherited libvips CVEs)
- mailparser 3.9.9 -> 3.9.14 (pulls linkify-it 5.0.2, CVE-2026-59887)
- brace-expansion override >=5.0.6 -> >=5.0.7 (CVE-2026-13149)
- body-parser 1.20.4 -> 1.20.6 via lockfile refresh (CVE-2026-12590)
* fix(images): migrate removed sharp failOnError option and enforce Node >=20.9 (stable)
sharp 0.35 drops the deprecated failOnError constructor option, so
recoverably corrupt images would start failing upload validation and
thumbnail generation; use the failOn: 'none' equivalent instead.
sharp 0.35 also requires Node >=20.9: declare it in engines and make
picpeak-setup.sh compare the full version instead of only the major,
so native installs on Node 20.3-20.8 upgrade instead of breaking.
* fix(setup): align the Node floor with the whole dependency tree and gate native updates (stable)
html-to-text@10 needs Node >=20.19 and the glob/minimatch family excludes
Node 21, so declare engines as ^20.19.0 || >=22 and enforce the same range
in picpeak-setup.sh. Also run install_nodejs at the start of
update_native_installation so existing native installs on an old Node get
upgraded before the service is stopped, instead of restarting broken.
* fix(setup): make the update-path Node gate actually work (stable)
--update dispatches before detect_os, so install_nodejs saw an empty
PACKAGE_MANAGER, matched no install branch, and reported success on the
old runtime. Detect the OS on demand and re-verify the installed version
afterwards, failing loudly (before the service is stopped) when the
runtime still misses the engines range, e.g. a Node 21 that package
managers refuse to downgrade.
* 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.
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 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.
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.
- Search now hits the backend (debounced) so results aren't truncated to the
first loaded page: /received gains a `q` filter (sender/subject); the frontend
passes the debounced term to every list query. The instant client-side filter
stays for responsiveness.
- Reply/compose recipient extracts the bare address from a "Name <addr>" From
header (extractEmail) — also used for the customer-lookup key.
- Added the full de + en `messages.*` and `email.customerMailbox.*` translation
namespaces (were English inline-fallbacks only). Swiss-German spelling.
- BLOCKER: stored XSS via inbound sender display name. The reply stub built raw
HTML with the unsanitized From name and set it as innerHTML on the composer's
contentEditable (admin origin) → onerror JS ran on Reply. Now HTML-escape
from_address in the stub AND DOMPurify-sanitize the composer body before
innerHTML (defense in depth).
- Gate the NEW Messages routes with requireFeatureFlag('messaging') per-route
(queue/:id, received/:id, item/*, identities, accounts, accounts/test, send)
— NOT the shared /email mount, so the pre-existing email-config endpoints stay
ungated.
- DocumentActionModal auto-picks a customer only on an EXACT email match
(customer search is prefix/fuzzy), else leaves the picker to the admin.
- Search box in the header filters the current folder's list (sender/subject),
client-side; works across the merged Archived/Deleted views too.
- Archive and Delete are now implemented as soft moves: migration 157 adds
mailbox_state ('active'|'archived'|'deleted') to email_queue + received_emails.
Archive → 'archived', Delete → 'deleted' (trash). Restore → 'active'. Deleting
FROM the Deleted folder is permanent (hard row delete).
- New cross-account system folders Archived + Deleted (merge sent + received of
that state, sorted by date). Normal folders now exclude archived/deleted.
- Backend: /queue + /received gain a `state` filter (default active + legacy
NULL); new POST /item/:kind/:id/state (archive/delete/restore) and DELETE
/item/:kind/:id (purge, email.edit).
- Toolbar Archive/Delete wired; Restore + "Delete permanently" shown in the
system folders.
Frontend build + migration boot (157) verified.
Pre-upstream review hardening:
- /accounts + /accounts/test now reject private/internal IMAP/SMTP hosts via
isPrivateIP(), matching /config + /incoming-config (SSRF).
- QueueDetail body iframe uses sandbox="" (script-less, no same-origin) like the
inbound pane, instead of allow-same-origin.
- /send sanitizer drops the <style> tag + data: scheme to match the stricter
inbound sanitizeBody allowlist.
- Per-account SMTP transport sets tls.rejectUnauthorized explicitly.
Addresses dev-test feedback:
- Sidebar + reading-pane addresses (rechnungen@ / hello@ / no-reply@) are now
read from the mail config via GET /admin/email/identities, not hardcoded.
- Highlight/selection now uses the branding accent (bg-accent-soft /
text-on-accent-soft / accent-dark) instead of hardcoded blue, so it follows
the admin's CI colour like the sidebar.
- Header gains "New message" (compose) and "Sync" (poll mailboxes now) buttons.
- Composer modal enlarged (920px, taller editable body).
- Customer mailbox (hello@) now has BOTH incoming (IMAP) and outgoing (SMTP)
settings — migration 156 adds smtp_* + from_* to mail_accounts;
emailProcessor.sendRawEmail takes an accountKey and sends via that mailbox's
SMTP identity (falls back to the global from). Manual/reply sends from the
Messages UI use the 'customers' identity, so replies come from hello@.
Frontend build + migration boot (156) verified.
The CRM action buttons and Reply now open a send-composer, not a silent
templated send.
- New send-composer (MessageComposer): loads the rendered template (via
previewTemplate) or a reply stub into a fully-editable body — the admin can
rewrite it or drop a note anywhere before sending. On send it goes out as-is
(server-sanitized), no template re-render.
- Backend: emailProcessor.sendRawEmail() sends admin-edited HTML via the
configured SMTP identity; POST /admin/email/send sanitizes + sends + records
the message in email_queue as a 'manual' send.
- Migration 155: email_queue.origin ('system' default | 'manual'). The Sent
stream now splits by origin — Automated ▸ Sent = system, Customers ▸ Sent =
the human/edited messages (which finally populates that folder). /queue gains
an origin filter + returns origin.
- Toolbar wired: Reply enabled on inbound customer mail (prefilled + quoted);
Create Quote/Contract/Invoice open the composer with that template loaded;
Gallery opens a blank compose. Accounting/Forward/Archive/Delete stay disabled
(later phases). After send, jumps to Customers ▸ Sent.
Deferred to a later phase: two-way IMAP write-back; per-identity SMTP (manual
sends currently use the global from address). Frontend build + migration boot
verified.
Address review on #764: backfillDunningRuns emitted invoice.sent without a
target, so enabling dunning would also enroll every historical open invoice
into any custom invoice.sent flow. Pass the enabled flow's id through to
emitWorkflowEvent so the backfill only touches dunning. Also note the
computeWakeAt both-fields (untilVar + delay) behaviour change in its comment.
Second inbound mailbox and real message bodies for the Messages viewer.
Backend:
- Migration 154: mail_accounts table (additional inbound mailboxes beyond the
primary accounting IMAP) + received_emails.{account_key,to_address,body_html,
body_text}. Additive/guarded.
- emailIntakeService now polls the accounting mailbox AND every enabled
mail_accounts row. Extracted pollAccountOnce(cfg, {accountKey, routeToExpenses});
accounting keeps its exact attachment->expenses behavior, customer mail is
logged with its body and NOT routed to accounting. Inbound HTML is sanitized
server-side (sanitize-html) on ingest.
- adminEmail: /received gains an account filter + returns account_key/to_address
(bodies excluded from the list); new GET /received/:id returns the body;
GET/POST /accounts + /accounts/test manage the extra mailboxes.
Frontend:
- Customers inbox now pulls the hello@ mailbox; reading pane renders the
sanitized body in a strict (script-less, no same-origin) sandboxed iframe.
Accounting inbox shows bodies too. Toolbar context keys off the mailbox.
- CustomerMailboxCard in Settings -> Email (behind the messaging flag) to
configure + test the hello@ IMAP box.
No behavior change to the existing accounting inbound flow. Frontend build +
migration boot verified.
New admin "Messages" page — a three-pane mail viewer over the mail picpeak
already stores, feature-flagged behind `messaging` (default off):
- Sidebar account tree: All mail / Customers (hello@) / Accounting (rechnungen@)
/ Automated (no-reply@), matching the agreed IA.
- Automated + All Sent = email_queue (listQueue); Accounting + All Inbox =
received_emails (listReceived). Customers folders show an explanatory empty
state pending the hello@ mailbox (Phase 2).
- Reading pane renders the sent body from rendered_html (migration 119) in a
sandboxed iframe; new GET /admin/email/queue/:id returns body + cc +
attachment filenames (disk paths never exposed).
- Received supplier invoices: envelope + rasterized PDF viewer reusing the
accounting inbound blob endpoint, plus "Open in Accounting inbox".
- Context toolbar (Reply/Forward/Create Quote-Contract-Gallery-Invoice /
Book-as-expense-Re-bill) present but disabled — wired in later phases.
Reuses email.service, accounting inbound blob endpoint, RequireFeature +
PermissionGate (email.view), Tailwind dark: theming. No schema change.
Addresses the PR #763 review: the invoice_sent / storno_issued / payment-check
/ paid-admin-notification emails share the identical event-first language bug
and never set __language, so a German customer on an English-gallery event got
an English email body with German-formatted amounts.
Each call site already computes the locale it formats amounts in, so this is a
one-liner per call — the body language now matches the amount formatting:
- invoice_sent, storno_issued (sending.js) -> __language: ctx.locale
- payment-check, invoice_paid_admin_notification (payments.js) -> __language: locale
Leak-safe (no template references {{__language}}) and falls back to the
existing event-first resolution when unset, per the mechanism added in #763.
Enabling the invoice-dunning built-in suppressed the legacy reminder ladder
but only created runs for invoices sent AFTER enabling — already-sent unpaid
invoices got dunned by neither. Now:
- Turning dunning ON enrolls every open sent/overdue unpaid invoice via
emitWorkflowEvent('invoice.sent') (engine.backfillDunningRuns(), wired into
the workflow enable toggle). Idempotent via the per-(flow,entity) dedup.
- The grace wait is anchored to the invoice's due date: computeWakeAt now
treats { untilVar, delayDays } as "var + offset" (was var-only OR now+offset),
and the built-in's waitGrace becomes { untilVar: 'dueDate', delayDays:
firstDays } (seed v6 -> v7). An already-overdue invoice duns on its real
timeline instead of restarting a fresh grace clock.
Note: the due-date-anchored graph applies to freshly seeded built-ins; an
already-admin-enabled dunning workflow still enrolls via backfill but keeps its
current grace timing until re-seeded.
- Billing/dunning emails no longer render in the gallery event's language.
emailProcessor now honors an explicit `__language` in the email data
(else falls back to the event-first recipient resolution), and the invoice
reminder passes the customer/invoice locale (customer.preferred_language
|| invoice.language || 'de'). Fixes German customers getting English
dunning notices. (#760)
- Payment-check confirmation card ("Action recorded") is now theme-adaptive
(green tint + readable text on both light and dark surfaces) instead of a
hardcoded light-green mix + dark-green title that vanished in dark mode. (#759)
- The per-customer "Preferred language" field already exists
(CustomerDetailPage) plus the business-profile default; updated the helper
text to note billing emails now honor it too. (#761)
Follow-on to the visibility fix in this PR — hero_logo_size had the same
split-brain: GalleryLayout read the global branding_logo_size live while
the hero-header path used the per-event snapshot, so a hero logo could
render at different sizes on different layouts and the global size didn't
reach hero-header galleries.
Now mirrored on the visibility model: NULL per-event hero_logo_size =
inherit branding_logo_size; explicit = override.
- Migration 153: hero_logo_size nullable + backfill NULL so existing
galleries inherit the global size (restores GalleryLayout's prior
live-global behaviour and fixes the hero-header staleness).
- Creation stores NULL unless explicit; gallery.js resolves
per-event ?? global and sends the effective size.
- GalleryLayout now consumes that resolved size for the hero logo (new
heroLogoSize prop) instead of the global — both render paths match.
- Admin size control gains a 'Use branding default' (inherit) option.
Verified: migration on SQLite + PG; live resolution (inherit follows
global both ways, override wins); creation stores NULL on PG; tsc clean,
106 adminEvents+gallery tests pass, build green.
Before: the global branding_logo_display_hero toggle was only a
creation-time default — snapshotted into each event's hero_logo_visible
column at creation and never consulted again. Disabling it did nothing
to existing galleries (the reporter's bug), and the two gallery render
paths disagreed (GalleryLayout read the global, HeroHeader read the
per-event snapshot).
Now: NULL per-event hero_logo_visible = 'inherit the global toggle';
an explicit true/false is a per-gallery override.
- Migration 152: make events.hero_logo_visible nullable and NULL out the
defaulted rows so existing galleries follow the global going
forward. Deliberate per-gallery hides () are preserved.
- Creation stores NULL unless the admin explicitly sets it; the update
path preserves NULL.
- gallery.js resolves per-event ?? global (branding_logo_display_hero,
default true) and sends the EFFECTIVE value on both gallery responses.
- Both frontend render paths now consume that resolved value
(GalleryLayout gets it via a new heroLogoVisible prop).
- Admin per-event control is now tri-state: Use branding default /
Always show / Always hide (en + de).
Verified: SQLite migration + live resolution (inherit follows global
both ways; override wins both ways); PG migration SQL dry-run; the admin
tri-state renders 'Use branding default' for an inherited event; tsc
clean, 106 adminEvents+gallery tests pass, build green.
From alexvaltchev's field UA list on #699. Adds CRAWLER-EXCLUSIVE tokens
to both the nginx UA regex and SOCIAL_CRAWLER_PATTERNS (kept in sync):
Cardyb (Bluesky's actual link-card fetcher), facebookcatalog, Signal,
Misskey, Pleroma, Synapse, Nextcloud, Rocket.Chat, kakaotalk-scrap,
Google-PageRenderer, OdklBot, ZoomBot.
Deliberately NOT added: UAs shared with real human in-app browsers
(WeChat MicroMessenger, LINE 'Line/', Zalo) and broad strings
('InAppBrowser', 'preview', 'unfurl', 'XING' → matches 'boxing'). Our OG
response is meta-only with no redirect, so matching those would serve a
human the bare stub. New negative test locks that exclusion in.
Verified: nginx -t passes; live harness confirms the new tokens rewrite
to /og while the in-app-browser UAs still get the SPA. Backend suite 15/15.
Follow-up to #699/#700/#702 — the OG SSR handler existed but three link
shapes never reached it behind the frontend nginx:
- Branded short URLs (/s/<slug>, #702) had NO nginx location, so they fell
through to the SPA — which has no /s/ route. Dead for humans (no 302
redirect) and crawlers (no OG). Add an ^~ /s/ proxy to the backend, whose
/s/:shortSlug route already handles both.
- Slideshow links (/gallery/<slug>/show/<token>) have TWO extra path
segments; the crawler-detect location regex allowed only one, so they
never rewrote to /og and got generic site-wide OG. Widen to {0,2} extra
segments (quoted regex — the braces would otherwise be parsed as nginx
config delimiters). client-access still matches (its token is in ?query,
one path segment).
- Viber's preview fetcher wasn't in either UA list, so Viber shares showed
no preview. Add it to nginx + SOCIAL_CRAWLER_PATTERNS (kept in sync).
Verified end-to-end: nginx -t passes; a live nginx+mock-backend harness
confirms /s/ proxies to the backend, slideshow + Viber + share-token +
client-access crawler UAs all rewrite to /og/gallery/<slug>, and browsers
still get the SPA. Backend isSocialCrawler test extended for Viber.
queuePaymentCheckEmail queued the admin payment-check email with template
key 'invoice_payment_check_admin', but no such template exists — the only
one is 'invoice_payment_check' (crmEmailTemplates.js:217, seeded by
migration 116), which IS the admin "Paid / Partial / Not paid" email. The
processor does an exact template_key lookup and throws "template not
found", so every dunning admin payment-check email failed, retried to the
cap, and got stuck pending.
One-word fix: queue 'invoice_payment_check'. Unbreaks the built-in
invoice-dunning flow's email step. (Rebased onto the post-decompose
invoiceService refactor — the line now lives in invoice/payments.js.)
Backend for #738. Real TOTP 2FA for admin accounts, all roles incl.
super_admin (closes#735).
- mfaService: otplib TOTP; AES-256-GCM encryption of the secret at rest
(key derived from MFA_ENCRYPTION_KEY or JWT_SECRET); bcrypt-hashed,
single-use recovery codes; otpauth URI + QR.
- Migration 151: adds two_factor_recovery_codes + two_factor_enrolled_at
(secret/enabled columns already existed from legacy 016).
- Enrollment endpoints (behind adminAuth, per-user): GET /mfa/status,
POST /mfa/{setup,enable,disable,recovery-codes}. Disable/regenerate
require a current code so a hijacked session can't strip 2FA.
- Login challenge: /admin/login returns {mfaRequired, mfaToken} (no
session) when 2FA is on; /admin/login/mfa exchanges a TOTP or recovery
code for the session. Lockout counter is NOT reset until the second
factor passes, so MFA brute-force is rate-limited too.
- CLI break-glass: scripts/reset-admin-mfa.js --email <e> | --all --yes,
audit-logged, matches reset-admin-password.js convention.
- Docs + optional MFA_ENCRYPTION_KEY env.
Verified end-to-end on a live backend: enroll (super_admin), challenge,
TOTP + single-use recovery login, disable, and CLI reset.
Auth/access-control audit fixes (all pre-existing on main; none are
regressions). Verified end-to-end where noted.
HIGH
- Thumbnail enumeration: photoAuth granted any gallery token access to any
flat /thumbnails/thumb_* file, so a visitor to one gallery could
enumerate another (password-protected) gallery's entire thumbnail set.
Scope thumbnail access to the token's event via photos.thumbnail_path.
Live-verified: cross-event fetch now 404s, own-event still 200s.
- Bulk ownership bypass: bulk-archive/bulk-delete acted on body-supplied
event ids with no owner filter (single-event routes enforce
requireEventOwnership), letting admin/editor archive or cascade-delete
any event. Add filterOwnedEventIds; also guard rename + import-external;
tighten photo-retry to scope admin (not just editor). Fix misleading
bulk-delete comment.
MED
- verifyGalleryAccess never checked decoded.type — assert 'gallery'
instead of relying on other token types incidentally lacking eventId.
- secure-images generate-token/secure-download missing denySlideshowToken
(#646 bypass): a leaked slideshow token could download originals.
- Frontend: AuthenticatedImage + api.ts attached the gallery bearer token
to absolute/external URLs — only attach to relative same-app paths.
LOW hardening
- Pin algorithms:['HS256'] on all auth-boundary jwt.verify calls.
- crypto.timingSafeEqual for share-token + HMAC compares (utils/timingSafe).
- Remove dead photoAuth import in galleryFeedback.
Tests: new regression suites for thumbnail scoping + filterOwnedEventIds;
fixed verifyGalleryAccess.customerRevoke fixture (real customer tokens
carry type:'gallery'). Full backend suite at the pre-existing baseline
(5 suites/27 tests fail on main too), zero new failures.
Same two pre-existing-on-main bugs, at their post-decomposition
locations: clampIntOrUndefined in adminEvents/crud.js slideshow seed;
!! coercion in EventDetailsHeader, EventInformationCard,
ClientAccessCard. Keeps this branch correct in either merge order with
#734 — when merging main afterwards, resolve the adminEvents.js
modify/delete conflict by keeping the deletion.
The create route seeds show_interval_ms/show_transition_ms from
app_settings through an inline guard that pre-checked Number.isFinite(+v)
but then used parseInt(v). The two disagree for null/''/true — +null is 0
(finite) while parseInt(null) is NaN — so when the slideshow settings rows
are absent (getAppSetting returns its null default), NaN flowed through
Math.min/Math.max into the INSERT. PostgreSQL rejects NaN for integer
columns; SQLite silently stores NULL, which is why every SQLite-based
test passed while POST /api/admin/events 500'd on the PG dev stack and
broke the e2e smoke suite.
Fix: parse first, then check — clampIntOrUndefined in utils/numericHelpers
(unit-tested against every failure-mode input). Verified end-to-end: the
previously-failing minimal create now succeeds against the PG dev stack.
- eslint --fix on branch-changed backend files (indent shift from the
module-wrapper nesting in decomposed files); backend lint now 904
errors vs 1,315 on main
- useMutationWithToast forwards all four TanStack v5 callback args
(tsc -b strict build flagged the 3-arg passthrough)
From the-luap's review:
- Import no longer trusts manifest.tables blindly. It now intersects the
manifest's table list with the real data tables of THIS database
(listDataTables(), which already excludes knex_migrations/_lock) and
drops anything else. A crafted/corrupted .picpeak listing knex_migrations
or a non-existent table can no longer wipe it; skipped tables are logged.
- The Postgres session_replication_role='replica' SET (needs superuser) is
now wrapped: on a managed-PG non-superuser it fails BEFORE any rows are
deleted (transaction rolls back) and surfaces a clear, actionable 400
instead of a cryptic permission error.
- Export: on an archiver error, the temp out dir (a partial plaintext-secret
archive) is now removed instead of orphaned.
Tests (+4, now 26): engine-mismatch rejection, forward-only newer-refused,
non-picpeak rejection, and files/ restored + filesRestored asserted.
Two Postgres-only bugs found by a live docker-pg roundtrip (SQLite tests
passed because neither reproduces on SQLite):
- Export: knex `.stream()` pulls in the optional `pg-query-stream` module
(not bundled) and throws on pg. Switched to a plain per-table `select`
— works on both engines, no new dependency. Rows are DB metadata
(blobs live under files/), so holding a table in memory is fine.
- Import: the pg driver returns json/jsonb columns as parsed JS values,
so re-inserting a scalar like the string "PicPeak" sent it unquoted and
pg rejected it ("invalid input syntax for type json"). Now introspects
each table's json/jsonb columns and re-serialises those values before
insert (pg only; SQLite stores json as TEXT and round-trips as-is).
Verified end-to-end on docker Postgres: export 85 tables, full-override
import, current account preserved, post-backup data removed.
POST /admin/backup/picpeak/import — multipart upload of a .picpeak,
streamed to a temp file (after auth, so unauthenticated requests can't
push a large file to disk), then restored via picpeakImportService with
currentAdminId = the logged-in operator (preserved across the override).
Gated on backup.restore. Returns usesExternalMedia so the UI can prompt to
reconfigure the external-media mount. Temp upload is always unlinked.
Completes the backend half of the GUI-only roundtrip (export download +
import upload). Multipart is already allowed by the CSRF content-type guard.
Receiving half of the roundtrip. picpeakImportService.importFromPicpeak():
- Validates the manifest: rejects non-picpeak files, a newer format, an
engine mismatch (pg↔pg / sqlite↔sqlite only), and a backup from a NEWER
schema than this instance (forward-only). knex_migrations absence is
tolerated (test harnesses).
- Snapshots the current logged-in admin, then wipes + reloads every table
from the backup NDJSON in one transaction with FK enforcement suspended
(pg: session_replication_role=replica reset before commit; sqlite:
defer_foreign_keys). knex_migrations is never touched, so the target's
schema/migration state is preserved.
- Re-injects the current account so the operator is never locked out; a
backup admin colliding on email is overwritten with the current creds.
- Restores files/ into storage and detects external-media references so the
caller can prompt to reconfigure the mount.
Roundtrip integration test proves: backup data restored, current account
survives a full override (different email → added), and the email-collision
case keeps the operator's password.