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.)
#730 — the account step's Create-admin button shared a flex row with Back;
its label + loading spinner exceeded the card width, so the button
overflowed the card outline while submitting (and was fragile for longer
i18n labels). Stack both buttons full-width — the primary always has room
for the spinner now, matching every other wizard step.
#732 — add a final 'community' step, shown once on first-run after
config / no-config, before entering the app. Mission line + four link
cards (report a bug, request a feature, star/share, Buy Me a Coffee),
all target=_blank rel=noopener, and a Finish → Dashboard button. Fully
i18n (en + de). Restore keeps its reload flow (a restored instance is no
longer first-run, so it never reaches this step). Adds .github/FUNDING.yml
so GitHub renders a Sponsor button too.
Verified live: drove the real first-run wizard end to end — stacked
account buttons render inside the card, community step shows the mission
+ all four links, Finish lands on the dashboard.
Frontend for #738.
- mfa.service.ts + MfaSettingsCard (Settings → General → Admin Account):
per-user setup (QR + manual secret + verify), recovery codes shown once
(copy/download/confirm), status, regenerate, disable. Renders for
super_admin (closes#735).
- Two-step login in AdminLoginPage: on {mfaRequired,mfaToken} swap to a
code step (TOTP or recovery), call /auth/admin/login/mfa; handle
MFA_INVALID / MFA_SESSION_EXPIRED / 423 lockout.
- Removed the non-functional global enable_2fa checkbox from SecurityTab
(and its persistence) — replaced with a note pointing to per-user setup.
- en + de i18n.
Verified live in-browser: enroll (QR→code→recovery codes), logout, and
the two-step challenge into the dashboard as super_admin.
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.
On SQLite deployments boolean event columns come back as 0/1, and
{event.is_draft && ...} renders the 0 as a literal text node. Visible on
the event details page in three spots: above the tab bar (is_draft),
in the download-protection badge row (disable_right_click /
enable_devtools_protection / watermark_downloads), and in the Client
Access card (client_access_enabled). Coerce with !! at the render sites.
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)
- 92 mutations across 40 files moved to useMutationWithToast
(success/error toast + invalidateKeys); complex flows left as-is
- 24 boolean modal flags moved to useModal
- Mutations without an original onError intentionally not migrated
to avoid introducing new error toasts
26 tests as a safety net ahead of decomposition — invoice create/list/
status transitions, adminEvents CRUD via Supertest+SQLite, backup config
parsing and manifest validation.
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.
When the chosen features need config the wizard can collect, 'Finish' on
the usage step now advances to a lean config step instead of jumping to
the dashboard:
- Invoicing (if Invoices): company/legal name, address, VAT-ID or tax
number, IBAN, currency → saved to business-profile + a default bank
account. Carries the bank/VAT legal disclaimer.
- Email (if reminders/incoming-mail/whatsapp/invoices): SMTP host/port/
user/pass/from → saved to email_configs.
Each section persists only if started, and 'Skip for now' is always
available — soft settings keep their seeded defaults. en + de strings.
The usage step now offers 'Migrating from another PicPeak?' → a restore
step that uploads a .picpeak (reusing PicpeakRestoreCard) to clone another
instance onto this fresh one, preserving the account just created. en + de
strings added.
Removes the redundant standalone .picpeak card. The wizard's 'Upload
Backup' source now splits into two kinds: '.picpeak backup' (the working
portable restore — renders the upload + destructive-confirm flow inline)
and 'Manifest + files' (legacy, still 'Manifest Upload functionality
coming soon'). en + de strings added.
The setup page background used var(--color-background), which flips to
#0a0a0a under the .dark class while the wizard card stays hardcoded light
— giving a dark page + light card mismatch in dark mode. Pin the first-run
screen to its intended light branded look (fixed #fafafa bg / #171717 text)
so all three steps render consistently.
Downloading a portable backup is a "make a backup" action, so it belongs
next to "Run Backup Now" on the Dashboard, not under Restore. Split the
combined card into PicpeakExportCard (Dashboard) and PicpeakRestoreCard
(Restore). The manifest stays bundled inside the .picpeak, so there is no
separate manifest-only download for the portable format.
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.
Adds a self-contained "Portable backup (.picpeak)" card to the Restore
tab, completing the GUI-only roundtrip:
- Download: optional "include original photos" toggle + a prominent
plaintext-secrets warning, streams the file via a blob download.
- Restore: file picker → destructive confirmation modal ("replaces ALL
data except your current account, cannot be undone") → multipart upload
to /admin/backup/picpeak/import → success summary. If the backup uses
external media, shows a banner to reconfigure the mount, with a docs link.
Kept separate from the legacy RestoreWizard (different format/flow). en+de
strings added; dark-mode variants throughout.
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.
After the admin account is created (and we're logged in), the wizard now
shows an opt-in feature step instead of jumping straight to the dashboard.
Grouped ticks (Client management / Accounting / Automation) map to the
existing feature flags; galleries/analytics/userManagement stay always-on
and are noted, not listed.
- Selection is saved via the existing authenticated PUT /admin/feature-flags,
whose server-side applyDependencyRules resolves dependencies (e.g. Invoices
pulls in Accounting) — the wizard only sends raw ticks.
- Labels/descriptions reuse settings.features.<key>.title/description so
translations stay in sync (en + de verified for all 14 features).
- Saving is best-effort: on failure the admin still enters the app and can
set features later in Settings.
- New en/de strings for the usage step.
Option A (lean wizard): this is the feature-selection foundation; per-feature
hard-required config steps + the restore-from-backup branch come next.
First half of the GUI-only backup roundtrip. Adds a self-describing
".picpeak" archive that can be downloaded from one instance and (later)
re-uploaded to another via the web UI only.
- picpeakExportService.createPicpeak(): dumps every table as NDJSON
(tables introspected at runtime — no hardcoded list, won't rot), plus
a manifest (format version, app version, DB engine, latest migration,
per-table row counts + checksums, includePhotos, contains_secrets),
plus files/ (business-docs + uploads always; original gallery photos
only when includePhotos). NDJSON is engine-neutral so the target
rebuilds schema via migrations then loads rows — enabling pg↔pg /
sqlite↔sqlite and forward-only auto-migrate.
- GET /admin/backup/picpeak/export?includePhotos= streams the file and
sets X-Picpeak-Contains-Secrets (the file holds plaintext SMTP pass,
admin hashes, API keys — the UI must warn).
- Purely additive: no existing backup/restore path is touched.
Integration test proves the archive shape, knex-table exclusion, and
row-count/NDJSON consistency (85 tables on the seed schema).
SettingsPage.tsx imported `Mail` from lucide-react twice — in the main
icon block (line 20) and again in a later import (line 58). The
@vitejs/plugin-react babel transform rejects the duplicate with
"Identifier 'Mail' has already been declared", so `npm run dev` crashed
when the module loaded. The production `vite build` (esbuild) silently
dedupes it, which is why CI/Docker builds passed and it went unnoticed.
The two imports overlap only on `Mail`; drop it from line 58, keeping
that line's six unique icons (Briefcase, Receipt, ScrollText, Landmark,
Smartphone, MonitorPlay). Verified: single Mail import remains, prod
build passes, and the vite dev transform of SettingsPage now returns 200
with no "already been declared" error.