2f00bbdd90ef38feca886e834e753d4c4b60c11d
100
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
2f63188a34 |
fix(events): TDZ ReferenceError on /admin/events from #442 fix (#454)
The pagination-clamp useEffect added in #448 (commit
|
||
|
|
49b36a0352 |
chore(migrations): renumber 090 → 096 + small notes from #403 review
Post-merge cleanups after #403 (customer portal): - Renumber 090_backfill_photo_dimensions_v2.js → 096 to follow #403's 090_add_customer_accounts ... 095_add_customer_portal_flag chain. - customerAccountsService.js: TODO note on must_change_password documenting that the column is decorative until an admin pre-loaded-password flow ships (mirrors what adminAuth does for must_change_password today). - customerAuth.js: doc-comment on the /login route explaining why the customerPortal feature flag deliberately doesn't gate it (toggle off hides UI, doesn't revoke existing-customer access; deactivate individual accounts to lock out). - 095_add_customer_portal_flag.js: header comment said "Migration 094" (copy-paste from 094) — now matches the filename. |
||
|
|
936a277eb8 |
fix(import): capture photo dimensions in fileWatcher + s3AutoImporter (#447)
The aspect-aware gallery layouts (masonry / mosaic / justified) read photo.width and photo.height to size each card to the source's real proportions. Two import paths were inserting rows without those fields, which forced MasonryGalleryLayout to fall back to a hard-coded 800×600 default — every card came out the same shape, so users reported masonry as "always cropped to 1:1ish" no matter which thumbnail fit mode they chose. - fileWatcher.js: extract dims with sharp.metadata() before insert. - s3AutoImporter.js: same, materialising a tmp local copy via withLocalCopy so it works in S3 mode. - migration 090: backfill any pre-existing rows with NULL dims (skips videos, skips S3 deployments — those need the writer fix alone since migrations cannot reach the storage backend). - imageProcessor.js: change DEFAULT_THUMBNAIL_FIT from 'cover' to 'inside' (only kicks in when the seed setting is missing — existing installs keep their saved value). Add UI tooltip recommending 'inside' for masonry/mosaic/justified, 'cover' for uniform grids. i18n covers all six locales. |
||
|
|
d62c529b02 |
fix(create-event): branding-default theme survives eventTypes refetch
The "apply recommended preset on event-type change" effect was firing on the initial mount AND every time the eventTypes API resolved (because availableEventTypes is recomputed when that query settles). The first fire matched the wedding default and clobbered the global Branding theme that the previous effect had just applied. Track the previous event_type in a ref and bail out when it hasn't actually changed. The Branding-default effect now wins on first paint, and the recommended-preset behaviour still kicks in when the user manually picks a different event type. Restores the green state of smoke spec 07 (#323-B regression). |
||
|
|
3a731e7c95 |
feat(footer): hideable legal links + socials + promo banner (#441 + #440)
Combined footer overhaul: - Per-CMS-page show_in_footer toggle (#441) — admins can hide Impressum / Datenschutz from the gallery footer when an external privacy / imprint URL is enough. - Five social-media URL fields in branding settings (#441) — Facebook, Instagram, WhatsApp, X/Twitter, YouTube. Empty string hides each icon individually; the row is omitted when none are set. - Promotional banner slot above or below the gallery footer (#440) — global default authored as markdown in branding settings, plus a three-way per-event override on the Edit Event form (inherit / custom / off). Backend nulls promo_markdown automatically when mode != 'custom' so stale text never persists. Sanitization: marked with gfm/breaks → DOMPurify with a tight allowlist (no img, no tables, no inline html). Post-process forces target=_blank rel="noopener noreferrer nofollow" on every link so admin-set URLs can't tab-nap the gallery context. i18n covers all six locales (en/de/nl/pt/ru/fr). Targets the beta branch. |
||
|
|
9c4a96fe97 |
fix(events): clamp page state when totalPages drops below current page (#442)
Bulk-deleting all events on the current page left the list empty until manual reload. After the React Query refetch returned `events: []` with a smaller `totalPages`, the page state was stuck on the old (now out-of-range) page index — the backend correctly serves an empty page for `page > totalPages`, but the UI had no logic to step back. Add a useEffect that watches `data.pagination.totalPages` against the current `page` and resets `page = max(1, totalPages)` whenever the result count shrinks. Fires after every refetch so it covers bulk delete, individual delete, archive, and any filter change that shrinks the result set — same one-line guarantee. Reported by @Rekoo-PS in #442. |
||
|
|
e54456135c |
fix(events): admins can clear expiration on edit even when "Require expiration" is ON (#426)
iSchumi6210 reported that with the global "Require expiration date"
toggle ON, an admin couldn't clear the expiration on an existing event
via the Edit Event form. The PUT returned 400 "Expiration date is
required."
The cause was intentional in the original code: the global setting was
enforced on both create AND edit, so once flipped ON, no event could
ever be cleared of its expiration — not even by admins editing one-by-
one. Reproduced the exact scenario byte-for-byte against beta:
Toggle ON → POST /admin/events {expiration_days: 30} → 200 created
Toggle ON → PUT /admin/events/:id {expires_at: null} → 400 rejected
The setting now controls only the create-time default. On edit, an
admin can clear the field and the value persists as NULL ("never
expires"). Matches CMS-style admin tool conventions where field-
required-by-default doesn't lock the field after creation.
Backend: drop the `getEventFieldRequirements()` enforcement on the
expires_at branch in PUT /admin/events/:id. Empty/null on edit
normalizes to NULL.
Frontend: drop the matching `requireExpiration && !editForm.expires_at`
toast in EventDetailsPage. The variable is no longer referenced, so
remove its declaration too.
Verified end-to-end with toggle ON:
STEP 1: create with expiration → ok (unchanged)
STEP 2: create without expiration → backend auto-applies default 30d
(create-time enforcement intact)
STEP 3: PUT {expires_at: null} on existing → "Event updated
successfully" (was 400)
STEP 4: DB column expires_at is NULL
STEP 5: PUT {expires_at: ''} also accepted (matches what an HTML
date input sends when cleared)
Smoke 13/13 green; no regressions.
|
||
|
|
15e333681f |
feat(settings): Features tab + sidebar reorg with feature-flag gating
Reorganises the admin sidebar around what users actually do, and adds a
single Features page that gates which feature surfaces appear in the
nav. Shrinks the main sidebar from 11 items to 4-6 (depending on
feature flags) and groups configuration screens into a single Settings
home with six logical sections.
Why
---
The current sidebar mixes three concerns: workspaces (Dashboard, Events,
Archives), feature surfaces (Analytics, Users), and configuration
screens that get touched maybe once a month (Email Settings, Branding,
Event Types, Backup, CMS Pages). That's 11 items, half of them config.
Backend
-------
- New `feature_flags` table (key, value, updated_at, updated_by).
Migration 088 detects existing-vs-fresh installs from the events
table:
* Existing install (events>0) → all 9 flags TRUE so nothing
vanishes from an admin's UI on upgrade.
* Fresh install (events=0) → spec defaults: galleries,
reminderEmails, analytics, userManagement TRUE; calendar,
calendarBooking, quotes, bills, messaging FALSE.
- New `/api/admin/feature-flags` (GET/PUT) under `settings.view` and
`settings.edit`. Server enforces the same dependency rules the
frontend does (galleries always TRUE, quotes=false → bills=false,
calendar=false → calendarBooking=false). PUT writes one
`feature_flags_updated` activity log row with the diff.
Frontend
--------
- `FeatureFlagsContext` provides `useFeatureFlags()` (with staged/save/
reset/isDirty) and `useFeatureEnabled(key)`. Mounted inside
AdminLayout so flag fetches carry the auth cookie. Source of truth
is the server response; staged is a local copy that the Features tab
edits and the Save button PUTs.
- `RequireFeature` route guard for /admin/analytics and /admin/users —
redirects to /admin/dashboard when the corresponding flag is OFF.
- AdminSidebar dropped from 11 to 6 items. Removed: Email Settings,
Branding, Event Types, Backup, CMS Pages (now Settings tabs).
Feature-gated: Analytics, Users.
- Old top-level routes (/admin/email, /admin/branding, /admin/event-
types, /admin/backup, /admin/cms) kept as <Navigate> redirects to
/admin/settings?tab=<key> so existing bookmarks don't 404.
- SettingsPage rewritten with a 6-group inner-nav (General /
Content & Appearance / Communication / Privacy & Security /
Integrations / System) and 19 tabs. New Features tab is the
default landing tab. URL ?tab=<key> roundtrips with state — deep
links and the back button work.
- FeaturesTab renders 9 cards across 5 sections. Toggles enabled for
Analytics + User Management (the two flags that gate sidebar items
in this PR). All other toggles disabled with a "Not yet available"
lockedReason — the cards still render so admins see the roadmap, but
the flag has no UI effect until the surface ships in its own PR. The
galleries card is locked TRUE per spec (foundation, can't be off).
- Live SidebarPreview reflects unsaved staged changes — admins see
what their sidebar will look like before they save.
- New i18n keys across all 5 locales (en, de, nl, pt, ru) for the
Features tab copy, the new Settings group labels, and the lifted
tab titles.
Verified end-to-end
-------------------
- Migration on this dev DB (existing install, 977 events): all 9 flags
set to TRUE.
- Migration on simulated fresh install (events table emptied): spec
defaults applied (5 OFF, 4 ON).
- Backend round-trip: GET → PUT → audit-log entry written, dependency
rule enforced (bills forced false when quotes=false even when bills=
true requested).
- UI Playwright spec: sidebar dropped 5 items, old top-level routes
redirect, Features tab is default, Galleries+Calendar+Quotes+Bills+
Messaging+ReminderEmails toggles disabled, Analytics+Users toggles
enabled, toggling Analytics off + saving updates the sidebar +
redirects /admin/analytics to /admin/dashboard.
- Smoke 13/13 still green; no regressions on existing flows.
|
||
|
|
83d79f4d39 |
fix(gallery): serve thumbnails / photos / hero via storage abstraction (#432)
Three gallery serving routes bypassed the getStorage() abstraction and
used fs.* directly against local paths. Worked in local-fs mode, 500'd
in S3 mode because the files only exist in the bucket. Reported by
@w1ll-i-code with a precise root-cause pointer at gallery.js:1138.
The admin photo serving route (adminPhotos.js) had already been
converted to use storage.stat + storage.get; the gallery side hadn't.
This PR brings the gallery routes in line.
Changes:
- Add getRange(relPath, start, end) to the StorageBackend interface +
LocalFsStorage (fs.createReadStream with start/end) + S3StorageBackend
(downloadStream with Range header). Needed for video range requests
on S3 — previously the photo route did fs.createReadStream(filePath,
{start, end}) which is local-only.
- /:slug/thumbnail/:photoId — read mtime via storage.stat, stream bytes
via storage.get. Watermark application path materializes the source
via withLocalCopy (no-op in local mode, downloads to a tmp file then
cleans up in S3 mode) so applyWatermark's sharp + fs.readFile still
works.
- /:slug/photo/:photoId — branches on source_origin: external/reference
photos still use the local fs path (NAS mounts are local), managed
photos use the storage abstraction. Video range requests pass through
to storage.getRange. Pre-generated watermarks served via storage too.
On-the-fly watermark generation uses withLocalCopy for managed photos.
- /:slug/hero/:photoId — hero images are always managed-storage keys
(imageProcessor.generateHeroImage writes via the storage abstraction),
so this just switches to storage.stat + storage.get. Watermark via
withLocalCopy.
Verified end-to-end against minio in dev:
POST /api/admin/photos/N/upload → photo + thumbnail land in S3
GET /api/gallery/<slug>/thumbnail/<id> → 200, JPEG 300x300 ✓
GET /api/gallery/<slug>/photo/<id> → 200, JPEG 1200x800 ✓
GET /api/gallery/<slug>/hero/<id> → 200, JPEG 1920x1080 ✓
ETag round-trip (If-None-Match) → 304 ✓
Backend logs → no errors
LocalFs regression: 13/13 smoke tests pass.
Closes #432.
|
||
|
|
5c7de96b7f |
fix(auth): default COOKIE_SECURE to 'auto' in production + first-install UX (#427)
Two intertwined bugs reported in #427 by @iSchumi6210: 1. Login silently fails over HTTP. Backend defaulted COOKIE_SECURE to true when NODE_ENV=production. Over plain HTTP the browser drops the Secure cookie → next /auth/session request returns 401 → redirect back to /admin/login → no error shown. picpeak-setup.sh writes NODE_ENV=production but never writes COOKIE_SECURE, so every first-time install without a reverse proxy hits this. 2. Admin password is generated but admins can't find it. The 001_init.js migration writes the generated password to data/ADMIN_CREDENTIALS.txt inside the backend container, but picpeak-setup.sh only copies it out when --reset-admin-password is passed. Default-path users never see it and resort to manual bcrypt updates in psql. Changes: - tokenUtils.js: production default goes from `true` to `'auto'`. On real HTTPS req.secure is true → Secure flag is still emitted (no security regression for reverse-proxy deployments). On plain HTTP req.secure is false → Secure flag omitted → login works. Users who explicitly want the strict HTTPS-only behaviour can still set COOKIE_SECURE=true. - .env.example: rewrite the COOKIE_SECURE block to make the new default obvious and explain when to override (set =true for strict, =false to skip the per-request check, leave unset for the auto behaviour). - picpeak-setup.sh (both Docker and native paths): - Write COOKIE_SECURE=auto explicitly to the generated .env (defense in depth so the right behaviour is preserved even if the backend default flips again later) - After migrations, ALWAYS copy ADMIN_CREDENTIALS.txt out of the backend container/data dir to the host data dir, chmod 600, and print the email + password to the install output. The credentials file remains as a backup record that the operator should delete after noting the password. Verified locally with all 4 permutations of NODE_ENV × COOKIE_SECURE: production, unset → HTTPS: secure=true ✓ HTTP: secure=false ✓ (was both true) production, =true → both: secure=true (strict opt-in preserved) production, =auto → HTTPS: secure=true HTTP: secure=false (already-correct) development, unset → both: secure=false (dev unchanged) |
||
|
|
f3d0f161c9 |
fix(external-media): pre-generate thumbnails so reference-mode galleries load fast (#423)
External (source_origin='external') photos always had thumbnail_path=NULL,
so the gallery returned thumbnail_url=null and every layout fell back to
streaming the full original from the NAS via the secure-image route.
With ~100 NAS-mounted photos that meant minutes of wall-clock load time,
sequential per tile.
Two halves:
1. import-external route generates the thumbnail right after each
successful insert and writes thumbnail_path on the row. Best-effort:
a single failure logs a warning and leaves thumbnail_path=NULL —
ensureThumbnail will retry lazily on first view. Synchronous in the
loop adds ~100-300ms per image; for the worst-case 1000-photo import
that's still under the typical request timeout.
2. ensureThumbnail() in imageProcessor handles external photos too —
resolves the local NAS mount path via resolvePhotoFilePath instead of
the storage-backend key. This covers existing externals already in
the database that were imported before this fix: first gallery view
per photo regenerates the thumbnail, subsequent views are fast.
Filename-collision protection: external thumbnails use
`thumb_ext<photoId>_<basename>` so two events both referencing
e.g. `IMG_0001.jpg` on different NAS subtrees can't clobber each other's
thumbnail. generateThumbnail accepts a new options.outputBasename to
support this without changing the managed-photo behaviour.
Verified locally with a 3-photo external dir and a real NAS-style import:
POST /api/admin/external-media/events/N/import-external
→ {imported:3, thumbnailsGenerated:3, thumbnailsFailed:0}
/api/gallery/<slug>/photos returns thumbnail_url for every photo
Lazy-regen path: clearing thumbnail_path + deleting the file, then
hitting /thumbnail/N regenerates and repopulates the row in 42ms.
Closes #423.
|
||
|
|
c2b1854df6 |
fix(admin): test email always sends, regardless of update availability (#418)
The "Send Test Email" button on the Update Notifications settings page called sendUpdateNotificationNow() — which bailed out with "No updates available" when the instance was already on the latest version. Admins on a current install had no way to verify their SMTP / recipient list was working until an update happened to be pending. Reported in #418 by @Rekoo-PS. Changes: - Add migration 087: insert a dedicated `version_update_test` email template (EN + DE, matching the existing version_update_available convention) with copy that reads as a config-check rather than as a real update notice. Subject prefixed with [TEST] so it's unambiguous in the inbox. Variables: current_version, channel, recipient_email. - Replace sendUpdateNotificationNow() with sendTestUpdateNotification() in updateNotificationService.js. The new path: - Always sends — no updateAvailable bail-out. - Uses the version_update_test template. - Falls back gracefully if checkForUpdates fails (so a transient GitHub API hiccup doesn't block a config-check email). - Does NOT update last_notified_version — that field stays owned by the real-update path so a test send doesn't shadow a future genuine notification for the same version. - Wire /admin/system/updates/notifications/send to the renamed function. No frontend change needed (the button already calls this endpoint). Verified locally with the dev mailhog: clicking Send Test Email on a 3.42.3-beta.0 instance (which has no pending update) delivers 4 emails to all admin recipients with subject "[TEST] PicPeak Update Notification — configuration check" and body interpolated correctly. Returns {success: true, successCount: 4, ...} — previously would have returned {success: false, message: "No updates available"}. |
||
|
|
99e420b1b9 |
fix(events): typed-DELETE confirmation for bulk delete (#417)
The bulk-delete modal previously used a password input as a confirmation gate, with an Enter-to-submit handler. Windows Hello / passkey flows that target password fields were able to autofill and synthesise an Enter keystroke, which submitted the form and triggered the destructive delete without an explicit click on the red Delete button (Rekoo's report in #417). Replace the password gate with a GitHub-style typed-literal pattern: the user types the literal "DELETE" (English, case-sensitive) into a plain text input. The Delete button stays disabled until the input matches, and there is no Enter-to-submit handler — only an explicit click on the red button proceeds. Plain text inputs aren't subject to password autofill or passkey ceremony so the auto-submit class of bug is gone. Server side, drop the bcrypt password verify on /admin/events/bulk-delete and the related INVALID_PASSWORD response. The server's auth boundary remains adminAuth + requirePermission('events.delete'); this matches DELETE /admin/events/:id which has never required a re-entered password. The client-side typed gate is the safeguard against accidental clicks. i18n: drop password-related keys, add confirmLabel + confirmHelp across en, de, nl, pt, ru. The literal "DELETE" stays English in all locales to keep the gesture immune to translation drift and unambiguous. Verified locally: typed-DELETE sanity spec covers the gate (wrong case disabled, correct enables, Enter-on-input no-ops, click submits, events deleted). Existing 03-bulk-archive smoke remains green. |
||
|
|
401abf7a27 |
fix(create-event): re-apply Branding theme on stale→fresh settings (#323-B)
CreateEventPage's branding-default effect used a boolean ref guard that locked in whichever theme_config arrived first. React Query can hand the observer a cached (stale) copy on initial render and then push fresh data once the network call resolves — the boolean ref meant the form kept the stale theme and ignored the fresh one. Replace the ref with a stringified-hash check: re-apply when the source actually changes (including stale → fresh) but skip when nothing has. User edits via the customizer aren't disturbed because settings.theme_config only refreshes on a real Branding save, not on form state. This unblocks the local pre-push smoke gate's 07-branding-default test, which was test.fixme'd against this exact React Query staleness. |
||
|
|
6b6191a426 |
fix(security): scan triage cleanup — drop dead deps, harden Docker/nginx/postMessage
Triage of an external SAST/SCA scan run on 2026-05-06. Most loud findings were already resolved by PR #412 (the 18-CVE backport); this PR addresses the residual real items: * Drop unused `handlebars` from backend deps. The runtime require was removed in PR #367 (#367) but the package.json line stayed. handlebars was the source of two flagged criticals (CVE-2026-33937 RCE, GHSA-2w6w-674q-4c4q AST injection) plus 8 highs — all now gone. * `npm audit fix` on backend + frontend. Bumps transitive picomatch, flatted, postcss, brace-expansion via lockfile, and direct dompurify, lodash, vite, i18next-http-backend within their existing semver ranges. Both audits now report 0 vulnerabilities. * Add `event.origin === window.location.origin` check to the THEME_PREVIEW message listener in PreviewPage. The branding page posts from the same origin, so nothing legitimate is rejected; without the check, any third party that window.open()'d the preview could push arbitrary branding/theme payloads (semgrep insufficient-postmessage-origin-validation). * nginx: `proxy_hide_header` for X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Content-Security-Policy, Permissions-Policy, Strict-Transport-Security at server level. nginx adds these itself, but helmet on the backend was also emitting them — clients were seeing duplicates (testssl flagged "Multiple X-Frame-Options / CSP / Permissions-Policy / Referrer-Policy headers" on the live origin). Single source of truth now. * Dockerfile hardening (checkov): - HEALTHCHECK on backend/Dockerfile, backend/Dockerfile.dev, frontend/Dockerfile.dev. Frontend production Dockerfile already had one. - USER node in frontend/Dockerfile.dev (was running as root). * GitHub Actions docker-build.yml: explicit top-level `permissions: contents: read`. Per-job blocks already declare `packages: write` where needed; this stops future steps from inheriting unintended privileges (CKV2_GHA_1). Backend npm audit: 4 vulns -> 0. Frontend npm audit: 6 vulns -> 0. Backend unit tests: 13 suites, 131/132 passing (1 pre-existing skip). Frontend type-check + lint: clean. The pre-existing integration-test failures (live DB / S3 required) and the ThemeCustomizerEnhanced QueryClientProvider failures are unrelated and reproduce on origin/beta without these changes. |
||
|
|
b7d6ca0b65 |
fix(security): patch 18 dependency CVEs (axios + transitives + nodemailer + i18next-http-backend)
Closes the open Trivy code-scanning alerts for app-side dependencies. The npm-bundled CVEs in /usr/local/lib/node_modules/npm (picomatch, brace-expansion, ip-address inside the Node image itself) are deferred to a separate Node-base-image PR — they're build-environment-side and need their own compatibility testing. ## Direct dependency bumps | Package | From | To | CVEs cleared | |---|---|---|---| | axios (backend + frontend) | 1.14.0 | 1.15.2 | CVE-2026-42264 (HIGH), CVE-2026-42043 (HIGH), CVE-2026-42035 (HIGH), CVE-2026-42033 (HIGH), CVE-2026-42044, CVE-2026-42042, CVE-2026-42041, CVE-2026-42040, CVE-2026-42039, CVE-2026-42038, CVE-2026-42037, CVE-2026-42036, CVE-2026-42034, CVE-2026-40175, CVE-2025-62718 | | nodemailer (backend) | ^7.0.13 | ^8.0.5 | GHSA-vvjj-xcjg-gr5g, GHSA-c7w3-x93f-qmm8 | | i18next-http-backend (frontend) | ^3.0.2 | ^3.0.5 | CVE-2026-41691 | | uuid (backend) | ^11.1.0 | ^11.1.1 | CVE-2026-41907 | | postcss (frontend, devDep) | ^8.4.21 | ^8.5.10 | CVE-2026-41305 | ## Transitive bumps (npm overrides) For transitives whose direct parents haven't released a version that picks up the patched range, pinned via npm overrides: | Package | Min | CVE | |---|---|---| | follow-redirects (backend + frontend) | >=1.16.0 | GHSA-r4q5-vmmm-2653 | | fast-xml-parser (backend) | >=5.7.0 | CVE-2026-41650 | | @tootallnate/once (backend) | >=3.0.1 | CVE-2026-3449 | | ip-address (backend) | >=10.1.1 | CVE-2026-42338 | ## Why axios is now safe to bump past 1.14.0 PR #268 originally pinned axios to 1.14.0 to avoid a supply-chain attack on a specific compromised version range. The 1.15.x series are post-incident upstream releases — clean. Confirmed with the maintainer before bumping. ## Verified * `npx tsc --noEmit` (frontend) — clean * `npx vite build` (frontend) — clean (~4s, existing bundle-size warning, not new) * Backend module-load smoke test — all critical modules load (`auth`, `adminAuth` middleware, `emailProcessor`, `recaptcha`, `storage`) with the new axios + nodemailer * Lockfile re-verification — every targeted CVE now resolves to the patched version range ## Remaining out of scope * npm-bundled CVEs inside `/usr/local/lib/node_modules/npm/` — picomatch CVE-2026-33671 (HIGH), CVE-2026-33672, brace-expansion CVE-2026-33750, ip-address (npm-internal) CVE-2026-42338. These live in the Node base image and require a Node base image bump with its own compatibility testing — separate PR. Targeting `beta` so the bumps go through the normal release-please flow before promotion to `main`. |
||
|
|
0c80abd57b |
fix(gallery): WCAG-safe Download button text + extract HeaderDownloadButton (#401 follow-ups)
Two follow-ups from PR #401's review: 1. Download button text was hardcoded `color: '#ffffff'`. Once admins start picking palettes via #400's expanded customizer, a pale accent (yellow, pastel blue, etc.) leaves the button unreadable — white text on near-white background. Fix: derive the foreground colour from the accent's WCAG relative luminance and expose it as the new `--color-accent-fg` CSS variable in ThemeContext.applyTheme. Light backgrounds (L >= 0.5) get black text; dark backgrounds get white. Same treatment applied to `--color-accent-dark-fg` for the filled-CTA token. The Download button now reads `var(--color-accent-fg, #ffffff)` so any future component that paints on accent gets the same treatment for free, and legacy deployments before the variable is set fall back to the previous hardcoded white. Threshold-based (rather than "highest contrast ratio") to preserve how saturated mid-tone accents have always rendered. The Picpeak default green (#5C8762, L≈0.20) keeps white text — same visual identity as before. Only genuinely pale accents flip to black, which is the actual scenario the review flagged. 2. The Download button JSX was duplicated three times in GalleryLayout.tsx (standard/banner, minimal, hero — ~15 lines each). Extracted into a small inline `HeaderDownloadButton` component above the GalleryLayout export. Three call sites now collapse to a 5-line component invocation each. Markup, accessibility, and styling live in one place — future tweaks only need to happen once. ## Files - `frontend/src/utils/contrast.ts` — new helper module: `relativeLuminance(hex)` (WCAG 2.x sRGB luminance) and `getReadableForeground(hex)` (white-or-black picker). - `frontend/src/utils/__tests__/contrast.test.ts` — 10 cases: fallbacks, saturated mid-tones, pale accents, near-black, shorthand `#RGB`, no-leading-`#`, case-insensitive, anchors (black/white luminance). - `frontend/src/contexts/ThemeContext.tsx` — wire the helper into `applyTheme`: set `--color-accent-fg` from `accentColor` and `--color-accent-dark-fg` from `accentDarkColor`/`primaryColor`. - `frontend/src/components/gallery/GalleryLayout.tsx` — extract `HeaderDownloadButton` component above `GalleryLayout`, replace three inline button blocks with the component, update its inline style to read `--color-accent-fg` (with the legacy `#ffffff` as the CSS-variable fallback). ## Verified - `npx vitest run src/utils/__tests__/contrast.test.ts` — 10/10 pass - `npx tsc --noEmit` — clean - `npx eslint` clean on every touched file - Default PicPeak green still renders white text (no regression) - Pale accent (#fef9c3 yellow-100) now correctly renders black text |
||
|
|
b106da1ede |
fix(auth): /auth/session must enforce session timeout symmetrically (#350 recurrence)
Third loop fix in the same /admin/login → /admin/dashboard → /admin/login pattern as #355 and #363. Reported on v3.39.1-beta.0 — the loop returns after a server restart or after an idle gap longer than the configured session timeout. ## Root cause (server) `sessionTimeoutMiddleware` is mounted on `/api/admin` (server.js:411). It rejects with `401 SESSION_TIMEOUT` when either: - the in-memory `lastActivity` for the token is older than the timeout, or - this is the first request with this token AND the token's `iat` is older than the timeout (post-restart guard). `/auth/session` lives under `/api/auth/session`, NOT under `/api/admin`, so the middleware never runs for it. Result: an idle/old-iat admin token returns `valid: true` from `/auth/session` while every protected endpoint immediately rejects it with `401 SESSION_TIMEOUT`. Frontend's 401 interceptor hard-redirects to `/admin/login`, `/auth/session` says valid again, loop closes — exact same shape as the previous two asymmetries the symmetry pass missed. Fix: add a non-mutating `isSessionExpired(token, decoded)` helper to `middleware/sessionTimeout.js` that reads the same in-memory map and applies the same lastActivity / iat-vs-timeout logic as the middleware, without updating the map (the middleware is the only place that records activity; `/auth/session` is read-only by design). `/auth/session` calls the helper for `decoded.type === 'admin'` after the existing admin-existence and password-change checks. Same try/catch fall-through pattern as the prior fixes so a missing/broken helper doesn't fail-closed during early bootstrap or in test stubs. ## Root cause (client race amplifying the loop) Even with the server fix, the previous `useSessionTimeout` hook called `AdminAuthContext.logout()` which dispatches `POST /auth/logout` fire-and-forget AND has its own `finally { window.location.href }`, then immediately set `window.location.href = '/admin/login?session=expired'` on top. Two consequences: - The cookie wasn't reliably cleared before the new page loaded — if any /auth/session asymmetry slipped through, the loop replayed inside the same tab. New-tab and "refresh several times" "fixes" were just the logout request eventually completing. - Two redirects raced; sometimes the `?session=expired` query was dropped, breaking the login-page toast. Fix: rewrite the hook to (a) await `POST /auth/logout` so the cookie is guaranteed cleared, (b) clear `sessionStorage.admin_user` directly instead of going through AdminAuthContext.logout (which has the side-effect redirect we don't want), and (c) navigate exactly once with the `?session=expired` query. ## Tests - `__tests__/routes/authSession.symmetry.test.js` — 4 new cases under a `session-timeout symmetry` describe block: helper says expired → valid:false; helper says active → valid:true; helper not called for gallery tokens; helper throws → fall through to valid:true (defensive). Existing 9 tests still pass (mock now includes `isSessionExpired: jest.fn(() => Promise.resolve(false))` as the default). - `__tests__/middleware/sessionTimeout.isSessionExpired.test.js` — 7 new unit tests for the helper itself: fresh token / old-iat / recently-active / null-input / no-mutation / 60-min default boundary cases. 20 cases total, all green. Lint clean on every touched file. |
||
|
|
51f28d7330 |
test(fonts): fix mock bypass and case-insensitive FS skip (#390 follow-up)
Two issues in the fonts service test suite added by #390 — the behaviour assertions all passed, but 5 of 24 tests had assertions that silently no-op'd, so any regression in those code paths would not have been caught. ## Issue 1: jest.resetModules() bypassed the logger mock `beforeEach` called `jest.resetModules()` then re-required `fontsService`. After resetModules, the `jest.mock('../../src/utils/logger', ...)` factory at the top of the file no longer applied to subsequent requires — so the freshly-required `fontsService` captured the REAL logger while the test file's `logger` variable still pointed at the mocked one. The 4 "warning logged" / "info logged" assertions resolved as 0 calls and silently passed-as-noop. The resetModules call wasn't necessary in the first place — module-level state in fontsService is just the cache, which clearFontsCache() already resets. And both getBundledFontsRoot() and getUserFontsRoot() read process.env at call-time, not at module load, so the env vars set in beforeEach are picked up without needing a fresh require. Fix: require fontsService once at module top (inside the jest.mock hoisting scope) and drop resetModules + the per-test re-require. ## Issue 2: case-insensitive filesystem (macOS / Windows) The "case-insensitive duplicate within the same root" test created `Inter/` and `INTER/` to trigger the dedup warning. On a case-sensitive FS (Linux ext4) both directory entries exist and the dedup branch fires; on macOS APFS or Windows NTFS the second mkdir resolves to the same folder as the first, so only one ever exists and the dedup is unreachable from this test setup. Test failed on macOS dev, passed on Linux CI. Fix: probe at load time by creating a lowercase file and checking if its uppercase variant resolves to the same inode, then conditionally test.skip the affected test on case-insensitive hosts. Comment in the test body explains why. ## Result 23 of 24 tests now pass on macOS; the case-sensitive-only test runs on Linux CI. All previously-no-op'd assertions now exercise their code paths. |
||
|
|
dbe0a3055b |
docs(readme): add Contributors section with @Luca-Timo and @Rekoo-PS
The Acknowledgments block had a generic "thanks to all contributors" line but no actual recognition by name. Two people in particular have moved the project meaningfully forward and should be called out: - @Luca-Timo — code contributor across multi-arch Docker, the external- URL CMS toggle, folder tree picker, admin email picker, self-hosted webfonts, the gallery header/banner decoupling, and typed-API refactors. Consistent quality. - @Rekoo-PS — bug reporter and feedback loop. Filed the issues that drove the login-loop fix, gallery loading skeleton, redirection cleanup, mobile lightbox overhaul, admin events search-counter fix, photo-count column, and bulk-delete workflow. Also a BuyMeACoffee supporter. Closes the implicit recognition gap and sets up the section so future contributors can be added with a one-line PR. |
||
|
|
48d538f94f |
feat(events): bulk delete with password confirmation (#384)
Adds the bulk-delete half of #384 — admins can select multiple events from the list and delete them in one batch, gated by re-entering their password. ## Why password confirmation Bulk delete is destructive and irreversible (cascades across 5 DB tables and 3 filesystem paths per event). Re-entering the password matches the pattern already used by /auth/admin/change-password and makes accidental clicks much harder than a plain "type DELETE to confirm" — the muscle-memory required to type your real password is a stronger gate than typing a literal word. ## Changes ### Backend (adminEvents.js) - Extracted the per-event cascade-delete logic into a module-private `deleteEventCascade(eventId, adminContext)` helper. The DELETE /:id route now calls it instead of inlining 60 lines of cascade — same behaviour, no drift between the per-event and bulk paths. - New `POST /admin/events/bulk-delete`. Body: `{ eventIds, password }`. Permission: `events.delete`. - Validates `eventIds` array length (1–100) and that each id is an integer. The 100-cap keeps request time bounded; the per-event cascade touches DB + filesystem so 1000 events at once would risk timing out the request. - Verifies `password` against the calling admin's bcrypt hash via `bcrypt.compare()` (same as /auth/admin/change-password). Wrong password → 401 `{ error, code: 'INVALID_PASSWORD' }` and no events are touched. - Loops via `deleteEventCascade`, returns `{ results: { successful, failed } }` with the same shape as /bulk-archive so the frontend can show partial-failure feedback. - Logs `bulk_delete_completed` activity with totals. ### Frontend - `events.service.ts`: `bulkDeleteEvents(eventIds, password)`. - New `BulkDeleteModal.tsx`. Red/destructive variant of the bulk-archive modal: - Lists the events to be deleted (so the admin can verify). - Password input with show/hide toggle, autofocus, Enter-to-submit. - Inline `passwordError` prop surfaces the 401 INVALID_PASSWORD response without losing the modal state — admin can retry without re-typing the event list. - "Processing" state replaces the form with a spinner + "Deleting N events. This may take a few minutes — please don't close this window." (i18n) so admins know not to abandon the page during a slow operation. - `EventsListPage.tsx`: "Delete Selected" button next to "Archive Selected" in the bulk-actions bar (red-styled to signal danger), bulkDeleteMutation that maps the 401 to the modal's inline error and any other failure to a generic toast. ### i18n 12 new keys under `events.bulkDelete.*` in all 5 locales (en/de/nl/pt/ru): title, warning, password label/placeholder/help, submit, processing, incorrectPassword, successAll, successPartial, errorGeneric, plus `events.deleteSelected` for the button. Hand- written for de; nl/pt/ru should get a native-speaker pass at some point but read naturally. ### Verified - `npx tsc --noEmit` clean - `npx eslint` clean on every touched file (4 pre-existing errors in adminEvents.js for unused vars unrelated to this PR) - All 5 locale JSON files parse cleanly - `node -e "require('./src/routes/adminEvents')"` loads the module Closes the bulk-delete half of #384. The Photos-column half lands separately in PR #387. |
||
|
|
ffb4318a1f |
feat(events): add Photos column to admin events list (#384)
The admin events table didn't surface how many photos each event
contained — admins had to click into the event to find out. The
backend already computes `photo_count` for every row in the
GET /admin/events list response (adminEvents.js:794-796), so this
is a frontend-only display change.
- Insert a "Photos" column between Date and Status — groups with
the "what's in this event" info.
- Right-aligned, tabular-nums for clean numeric alignment in the
column.
- Reuses the existing `events.photos` i18n key already shipped in
all 5 locales for the EventDetailsPage tab list ("Fotos" / etc.) —
no new translations needed.
- Updates the empty-state colSpan from 7 to 8.
Closes the column-add half of #384. The bulk-delete request from
the same issue lands separately.
|
||
|
|
98c6f6cf06 |
chore(events): type FolderTreeNode entries with ExternalEntry
Follow-up to PR #378 — drops the (e: any) / (d: any) casts in the external-folder-tree picker. ExternalEntry is already exported from externalMedia.service.ts; the call site just wasn't using it. - Import the type alongside the service. - Annotate the dirs filter callback so `e.type` is the union 'dir' | 'file' instead of any. - Drop the (d: any) annotation from the map — TypeScript infers ExternalEntry from the typed `dirs` array. No behaviour change, no test impact. `npx tsc --noEmit` clean, `npx eslint` clean. |
||
|
|
96818c7ae8 |
fix(docker): install system ffmpeg on Alpine, drop broken bundled binary
Video uploads on production fail with "missing ffmpeg" because the
backend container ships nothing usable for the video pipeline.
Two compounding causes:
1. **Alpine + glibc mismatch.** The npm `@ffmpeg-installer/ffmpeg`
dependency added with the video-support PR (commit
|
||
|
|
bce5c1f725 |
fix(cms): nl/pt/ru i18n + gate external_url in public response
Two follow-ups to PR #372 (external-URL toggle for imprint / privacy CMS pages): 1. **i18n.** PR #372 added 6 new `cms.*` keys to the en + de locales but the project ships 5 locales total. Adds the missing nl / pt / ru translations so the admin CMS page renders in the active language for those users instead of falling back to English literals next to the German/Dutch/Portuguese/Russian surrounding strings. 2. **API shape.** `publicCMS.js` returned `external_url` unconditionally — even when `use_external_url` is false the URL value was still emitted in the public response. The frontend correctly gated on both flags so it worked, but the API surface was leaking a value the admin had explicitly disabled. The value still lives in the DB (so the toggle can be flipped back on without losing it), but the public endpoint now returns `null` whenever the toggle is off. Note: kept the existing `logo_url` shape unchanged. Its semantics are different — null means "fall back to global branding" and consumers rely on always having the field, so emitting it unconditionally is intentional there. No frontend change needed: both `GalleryLayout` and `LegalPage` already gate on `use_external_url && external_url`, so the short-circuit handles `external_url: null` correctly. |
||
|
|
c270bcfc9f |
i18n(events): translate PasswordResetModal across 5 locales
The rebuilt modal in this PR shipped with hard-coded English strings.
That made the reset flow untranslated for German/Dutch/Portuguese/
Russian customers — toasts, confirm dialog, success screen all
fell back to English regardless of the active locale.
- New `events.passwordReset.*` namespace in en/de/nl/pt/ru with 22
keys covering both modal screens, the warning banner, validation
errors, and the toast messages.
- Modal uses `useTranslation()` for every previously hard-coded
string. Reuses `common.cancel`, `events.copy`, `events.copied`
where they already exist across all locales.
- The {{eventName}} interpolation uses i18next's standard variable
syntax so the description line reads naturally in each language.
No behaviour change. TypeScript clean (`npx tsc --noEmit`), ESLint
clean. JSON validity checked for all 5 locale files.
|
||
|
|
ff50c74e19 |
fix(events): admin-set password on reset, full-URL gallery_link in all emails
Two related defects on the same gallery-email surface that PR #367 opened, addressed together: 1. Reset-password endpoint was a one-way auto-generate. `POST /admin/events/:id/reset-password` always called `generateReadablePassword()` and ignored any client-supplied value; the modal only offered a confirm + a forced auto-generated result. Admins who wanted to set a memorable customer-supplied password had no way to do it. Backend: route now reads optional `password` from the body. If present, validates with `validatePasswordInContext('gallery', …)` (same rules as create-event) and uses it; if absent, falls back to the existing generator, so old callers / cron stay functional. Switched the bcrypt rounds from a hard-coded `10` to `getBcryptRounds()` to match the create flow. Frontend: rebuilt `PasswordResetModal.tsx`. Typed input with show/hide, confirm-password field that appears on type, the same `<PasswordGenerator>` used by `CreateEventPage` (event-context- aware, fills both fields when used), send-email checkbox, client-side validation, server-side validation feedback inline. Submit empty → server auto-generates and the success screen shows the value with a copy button (legacy one-click flow preserved); submit with a typed password → success toast + close (no need to re-show what the admin already typed). Service layer: `events.service.resetPassword(id, sendEmail, password?)` only sends `password` in the body when set. Caller: `EventDetailsPage` now passes `eventDate` + `eventType` into the modal so the generator has event context. 2. `gallery_link` was the path-only `event.share_link` in three email-queue sites, so customer mail showed `/gallery/<slug>/<token>` instead of the full `https://example.com/gallery/<slug>/<token>` URL. - `adminEvents.js` reset-password queue (#1437) - `adminEvents.js` resend-creation-email queue (#1502) - `expirationChecker.js` expiration_warning queue (#82) All three now derive `shareUrl` from `buildShareLinkVariants` (the same helper already used by create-event, publish-from- draft, and event-rename). The other 4 callers (`adminEvents.js:651/913`, `events.js:187`, `eventRenameService.js:231`) already used the full URL — this closes the gap. Verified: TypeScript clean (`npx tsc --noEmit`), ESLint clean on every touched file (the 4 lint errors that remain in `adminEvents.js` are pre-existing and predate this branch). |
||
|
|
e8052adf1d |
fix(email): render conditionals, localise password placeholders, fix caller/template variable drift
Bundle of email-renderer and email-caller fixes triggered by a
reproducer on picpeak.nothaft.cloud (gallery_created mail showing
literal `{{#if welcome_message}}` markers and `Passwort: (set at
creation)`). The audit that followed surfaced six more user-visible
defects in the same surface; all are fixed here so customer-facing
mail renders cleanly.
Renderer (`backend/src/services/emailProcessor.js`)
- `safeTemplateReplace` now resolves `{{#if VAR}}…{{/if}}` blocks
before flat `{{var}}` substitution. The shipped templates have used
Handlebars-style conditionals since migration 026; the renderer
ignored them, so the markers leaked verbatim into every mail with
an empty welcome_message. Lifted to module scope and exported so
the conditional contract is unit-testable. Single-pass, non-nested
(commented).
- Added `passwordSetAtCreationI18n` next to the existing two i18n
password sentinels so `(set at creation)` (sent by the publish-
from-draft flow when only the bcrypt hash remains) is localised
to "Das bei der Erstellung der Galerie gesetzte Passwort" /
equivalent in EN/DE/NL/PT/RU instead of the raw English string.
- Added an opt-in `{ escapeHtml: true }` mode to `safeTemplateReplace`
so admin-supplied free text (`event_name`, `host_name`, …) is
HTML-escaped on substitution into the HTML body. Allowlist of
passthrough keys (`welcome_message` already-HTML, server-generated
URLs `gallery_link` / `client_link`). Subject and text body keep
the legacy unescaped behaviour. `formatWelcomeMessage` now escapes
before nl2br so the welcome_message allowlist is safe.
- New `htmlToText()` strips `<style>` and `<script>` blocks (and
their content) before tag-stripping, decodes common entities, and
collapses whitespace. Used by the textBody fallback in
`sendTemplateEmail` — without this, every template missing a
`body_text` produced a "plain-text" mail starting with the 100+
lines of CSS embedded by `wrapEmailHtml()`.
- The client-access section (#172) now mirrors its HTML block into
`textBody` using the same per-language strings, so plain-text
recipients see the link / PIN / warning. `pinLabel = 'PIN'` moved
into `clientAccessI18n` (RU uses ПИН-код).
- Added `getSupportEmail()` exported helper that reads
`branding_support_email` from `app_settings` (JSON-decoded), with
the SMTP from-address as fallback. Used by the gallery_expired and
archive_complete callers below.
- Removed dead `require('handlebars')` (unused since the regex
renderer landed; pre-existing lint error in this file).
Callers (data the templates already reference)
- `expirationChecker.js queueExpirationWarning`: send `expiry_date`
(templates use this, the old code sent `expiration_date` —
typo'd key, never read), drop the hard-coded `.de`/`en` sniff
(the processor formats with the recipient's resolved language),
add the `{{password_security_message}}` sentinel for
`gallery_password` (plaintext is gone by warning time, so
customers used to see literal `{{gallery_password}}` in the mail).
- `expirationChecker.js handleExpiredEvent`: both queueEmail calls
now supply `host_name`, `event_date`, `expiry_date`,
`support_email` so the EN/DE/NL/PT/RU `gallery_expired` template
doesn't render literal `{{host_name}}, your gallery expired on
{{expiry_date}}`. Skip the duplicate admin send when
admin_email == customer_email.
- `archiveService.js`: `archive_complete` queue now supplies
`host_name`, `photo_count` (from `photoEntries.length`),
`archive_date`, `support_email` — the previous payload had only
`event_name` and `archive_size`, so most of the mail was
unfilled placeholders.
Tests
- `__tests__/services/emailProcessor.safeTemplateReplace.test.js`:
16 cases — flat substitution, conditional truthy/falsy/missing/
multi-line/sibling/numeric-0, plus 5 cases for the new
`escapeHtml` option (default off, escape on, allowlist
passthrough for welcome_message and gallery_link).
- `__tests__/services/emailProcessor.htmlToText.test.js`: 7 cases —
the regression scenario (full wrapped body with embedded `<style>`
block), tag-stripping, entity decoding, paragraph spacing.
- `__tests__/utils/formatters.test.js`: 12 cases for `escapeHtml`,
`nl2br`, and the now-escaping `formatWelcomeMessage`.
35 cases total, all green. Lint clean on every touched file
(also fixes a pre-existing `no-prototype-builtins` warning in the
process). Pre-existing failures in
`__tests__/services/backupService.enhanced.test.js` are unrelated
and pre-date this branch.
|
||
|
|
b30010eb6f |
test(upload): unit tests for backgroundProcessor + processPhoto
Cover the two new pieces of the async pipeline:
backgroundProcessor.claimNextPhoto
- returns null when no pending rows
- returns the row + flips status under postgres FOR UPDATE SKIP LOCKED
- returns null when SQLite UPDATE-with-guard loses the race
- returns the row when the SQLite guard wins
photoProcessor.processPhoto
- happy path: writes thumbnail / dimensions / EXIF capture date and
marks 'complete'; fires watermark queue + photo.uploaded webhook
with the right payload
- video path: writes ffmpeg duration / codec / dimensions; does NOT
queue watermark (image-only)
- throws cleanly when the photo row no longer exists
Mocks db / imageProcessor / videoProcessor / storage / sharp /
watermarkGeneratorService / webhookService / logger so the tests run
without a real DB or any image library calls — fast and deterministic.
|
||
|
|
3b827b80d5 |
feat(upload): async photo processing — frontend (PR-B part 2)
Live processing-state UI that complements the backend async pipeline.
Modal stays open through the processing phase and surfaces real
progress (X of N photos processed); the admin grid renders placeholder
cards for in-flight photos and auto-refreshes via polling until the
queue drains.
services/uploads.service.ts (new)
- getStatus(uploadId) — JSON snapshot from /admin/uploads/:id/status
- retryPhoto(photoId) — POST /admin/photos/:id/retry
- streamUrl(uploadId) — SSE upgrade URL
hooks/useUploadProgress.ts (new)
- Tracks N concurrent upload IDs (one per chunk POST) and merges
counters into a single aggregate.
- Always polls every 1.5s; opportunistic SSE upgrade on top of that.
SSE failure (proxy buffering, etc.) silently downgrades to polling
only — no reconnect storms.
- Auto-stops both channels when every tracked group is in a terminal
(complete/failed) state.
components/admin/PhotoUpload.tsx
- Captures upload_id from each chunk's 202 response, feeds them into
useUploadProgress.
- Phase machine extended: stays in 'processing' until the worker
drains the queue (not just until bytes-on-wire). Progress UI shows
real "X of N done" with a determinate bar fed by the aggregate.
- "You can leave this page" hint kept — closing the modal is now
actually safe, work continues server-side.
- Side-effect refactor: invokes onUploadComplete twice — once early
so the user sees photos appearing immediately, once on terminal
so the parent grid sees final state.
components/admin/AdminPhotoGrid.tsx
- Photos with processing_status pending/processing render an amber
placeholder card with a spinning Cog instead of the missing
thumbnail.
- Photos with status='failed' render a red card with the error message
and a "Retry" button that POSTs /admin/photos/:id/retry.
pages/admin/EventDetailsPage.tsx
- Photo list query gains refetchInterval that polls every 2s while
any photo is non-terminal, then stops. Keeps the grid auto-fresh
during ongoing processing.
|
||
|
|
851744c3c4 |
feat(upload): async photo processing — backend (PR-B part 1)
Move thumbnail / EXIF / dimensions / watermark / webhook work off the
upload request thread and into a background worker pool. Upload
requests now return 202 in seconds even on NFS-backed storage; the
worker(s) drain the pending queue independently and update each
photo's processing_status to 'complete' or 'failed' on its own.
Schema (migration 085_async_photo_processing.js):
- photos.processing_status enum default 'complete' (existing
rows are already done)
- photos.processing_error populated on 'failed'
- photos.processing_started_at timestamp for janitor recovery
- photos.upload_id groups all photos from one upload
request so the frontend can poll
status by group
- indexes on processing_status and upload_id for queue lookups
services/photoProcessor.js
- queueFilesForProcessing(files, options) — shared helper used by
the admin and gallery upload routes. Moves files to final storage
+ inserts pending rows; returns { uploadId, photos, errors }.
- processPhoto(photoId) — worker-mode: reads original from storage
via withLocalCopy (transparent local/S3), generates thumbnail and
EXIF/dimensions or video metadata, queues watermark, fires
photo.uploaded webhook, marks 'complete'. Throws => caller marks
'failed' with the error message.
- processUploadedPhotos kept untouched — chunkedUploadService still
uses the synchronous path.
services/backgroundProcessor.js (new)
- N independent worker loops per backend instance (default 2,
UPLOAD_PROCESSOR_CONCURRENCY env override).
- Multi-pod safe: postgres SELECT FOR UPDATE SKIP LOCKED, sqlite
UPDATE-with-status-guard. Pods race on rows, exactly one wins.
- Janitor every minute resets photos stuck in 'processing' for >10
minutes (worker died, pod restarted) back to 'pending'.
- UPLOAD_PROCESSOR_DISABLED=true opt-out for CI/test.
- Started from server.js after the other long-running workers.
routes/adminPhotos.js — POST /:eventId/upload
- Replaced batch-of-25 sync processing loop with per-file
move-to-storage + insert-pending. Response is now 202 with
upload_id, count, photo_ids in addition to the legacy
successCount / replacedCount fields the existing frontend reads.
- Per-request temp directory cleanup is now a single idempotent
handler on res.finish/res.close (was three inline blocks for
error paths only, leaking dirs on success — original bug from
contributor analysis).
- GET /uploads/:upload_id/status — JSON snapshot of pending /
processing / complete / failed counts plus per-photo state.
- GET /uploads/:upload_id/stream — SSE upgrade. Polls internally
every 1.5s, emits on snapshot change, ends when all photos
reach a terminal state.
- POST /photos/:photoId/retry — flips a 'failed' photo back to
'pending' so the worker picks it up again.
- GET /:eventId/thumbnail/:photoId now returns 503 with Retry-After
while the photo is still pending/processing, and 422 on 'failed'.
The admin grid renders placeholders accordingly.
routes/gallery.js — POST /:eventId/upload (guest)
- Refactored to use queueFilesForProcessing instead of the synchronous
processUploadedPhotos. Same 202 + upload_id shape.
- GET /:slug/photos now filters processing_status to 'complete' (or
NULL for pre-migration rows) so guests never see in-flight photos.
Side-effect timing change:
- photo.uploaded webhook now fires from the worker after the photo
is actually processed (thumbnail + dimensions populated) instead
of from inside the upload request. Same payload fields. Worth a
one-line note in the changelog.
|
||
|
|
86dfcc4f11 |
feat(upload): two-state UI + temp dir cleanup (PR-A of async processing)
Phase 1 of the upload-progress redesign. Two changes that ship UX wins
without any architectural surgery — they're a stepping stone for the
full async-processing rework that follows in subsequent commits.
1. Two-state progress bar (PhotoUpload.tsx, UserPhotoUpload.tsx)
When axios.onUploadProgress reports loaded === total, the request is
on the server and the bytes have left the browser. Today the bar sits
at 100% for the chunk while the backend runs sharp/ffmpeg/EXIF (often
minutes on NFS-backed storage) and users assume the upload froze.
The component now distinguishes two phases:
- 'transferring' — bytes-on-wire, determinate progress bar.
- 'processing' — bytes done, waiting for response. Indeterminate
spinner + an explanatory hint that the backend is
generating thumbnails / reading metadata and the
user can leave the page.
Same pattern in UserPhotoUpload (gallery): the per-file checkmark
icon is replaced by a Loader2 spinner while the request is in flight
after bytes-on-wire finished.
2. Temp directory cleanup (adminPhotos.js)
Multer creates temp/upload_<ts>_<rand>/ per request. Files inside it
are individually unlinked after they're moved to storage on the
success path, but the empty directory was never removed. On error
paths three different inline blocks each tried to clean up; the
success path was missed entirely. Result: the orphan-empty-dirs
accumulation reported in the issue (70+ on the affected instance).
Replace the inline cleanup blocks with a single idempotent
cleanupTempDir() registered on res.finish + res.close, so it fires
exactly once on every exit path (validation 4xx, server 5xx, multer
error, success).
New translation keys (en/de): upload.transferring, upload.processing,
upload.processingHint, upload.processingProgress, upload.processingFailed,
upload.retryFailed.
|
||
|
|
f905f7e733 |
fix(auth): /auth/session must reject tokens that adminAuth/galleryAuth would reject
Second loop fix in the same /admin/login → /admin/dashboard → /admin/login pattern as #355. The frontend trusts /auth/session as the source of truth for "is the user authenticated?". When that endpoint is more lenient than the protected middleware, every admin endpoint 401s right after /auth/session said valid:true, the response interceptor hard-redirects to /admin/login, /auth/session says valid again, and the cycle closes — exactly the loop reported on v3.32.4-beta.0. #355 fixed the issuer-claim asymmetry. This commit fixes the remaining asymmetries: /auth/session was missing the admin-existence, admin-active, password-change-after-iat, and gallery-existence / gallery-archived / gallery-expired checks that adminAuth and galleryAuth perform on every protected request. The fix is to mirror those checks in /auth/session, scoped by token type, and degrade gracefully when the underlying tables aren't present (test fixtures, early bootstrap) so the endpoint never fails-closed because of a missing table. Reproducer that the new test covers: 1. Admin logs in (token issued at T). 2. Admin (or another admin) changes their own password at T+1. 3. Browser still has the cookie from T. 4. /auth/session says valid:true (no password-change check). 5. /admin/dashboard fires queries; adminAuth rejects with PASSWORD_CHANGED 401. 6. Frontend redirects to /admin/login. 7. /auth/session says valid:true again. → loop. Other surfaces this also covers: - admin user deactivated (admin_users.is_active = false) - admin user deleted - gallery token whose event is archived - gallery token whose event has expired Tests live in __tests__/routes/authSession.symmetry.test.js — 9 cases, mocking db / tokenRevocation / tokenUtils / recaptcha / sessionTimeout so the suite runs without a real database. |
||
|
|
7b2f75e6ae |
chore: bump @playwright/test to ^1.57.0; drop unused root dotenv
The root devDependencies still pinned an older Playwright (1.48.2) plus a stray `dotenv` that nothing in the e2e suite or root scripts actually requires (verified via grep across tests/). Updates the Playwright version to match the current upstream stable and removes the unused dotenv to keep the root install lean. Originated from a local stash that picked up these changes; landing them as a small dedicated commit so they don't blend into the auth fix that follows. |
||
|
|
1a530aeaa2 |
fix(theme): kill initial white frame + theme-aware skeleton tiles (#358 follow-up)
Two further fixes for the gallery loading sequence shown in @Rekoo-PS's frame breakdown on issue #358 — both about colours that didn't track the active theme. 1. Initial white frame (frame f1) The pre-React bootstrap script in #359 sets the cached background on documentElement, but the browser may paint the very first frame *before* that <script> tag runs (synchronous parse-time JS in the <head> is still slightly later than CSS apply-time). On first-visit dark-OS devices that meant a single white frame before the script resolved. Fix: move the OS-preference default into a <style> block that precedes the script. CSS @media (prefers-color-scheme) is applied before paint, so dark-OS devices land on dark from frame zero. The script keeps the per-gallery cache hit on top, and now also stamps the colour onto document.body in case the body element has already mounted by the time the script runs. 2. "Most annoying" skeleton tile frame (frame f4) Skeleton placeholders rendered as bright `bg-neutral-200` light grey regardless of theme. On a dark gallery that's the highest-contrast thing on screen during loading — the exact frame Rekoo-PS labelled "the most annoying" in the issue. Fix: the Skeleton component's background now reads `var(--color-surface-border)`, which ThemeContext already wires up per active theme (`#e5e5e5` light / `#2e2e2e` dark by default; per- event themes can override). The bare `<div>` no longer carries any colour utility class — the inline style supplies the active value. Also dropped the leftover `bg-white` on SkeletonCard / SkeletonTable in favour of `var(--color-surface)` for the same reason. Tests: New src/components/common/__tests__/Skeleton.test.tsx covers - Skeleton uses var(--color-surface-border, ...) - bg-neutral-200 is no longer present - SkeletonGalleryGrid tiles all inherit the theme colour - SkeletonCard surface uses var(--color-surface) |
||
|
|
ef1c875f6e |
fix(events): stop mapping branding_logo_position onto hero_logo_position
Two settings with overlapping names but different value sets were being
conflated:
- branding_logo_position (header bar, horizontal): 'left'|'center'|'right'
- hero_logo_position (hero block, vertical): 'top'|'center'|'bottom'
getBrandingDefaults() copied the global branding value over the per-event
hero value when seeding new events. Any admin with branding logo set to
'left' (the most common choice) created events with hero_logo_position
= 'left' written to the DB. Subsequent PUTs to /admin/events/:id then
failed validation with "Invalid value (field: hero_logo_position)" — the
validator only accepts top/center/bottom.
Fix:
1. Drop the bogus mapping. branding_logo_position is no longer read by
getBrandingDefaults — it doesn't belong there. The fallback default
('top') is used unless the request body explicitly provides
hero_logo_position, which is independently validated.
2. Migration 084_fix_hero_logo_position normalises any existing rows
whose hero_logo_position is outside ('top','center','bottom') back
to 'top'. Without this, affected events would continue to 400 on
every save until the admin manually picks a valid option.
Reproduction: admin sets branding logo position to 'left' under global
branding, creates an event, opens the event detail page, clicks Save
without changing anything → 400. After this fix, save succeeds and new
events default to 'top' regardless of branding-bar position.
|
||
|
|
f81a8728e6 |
fix(theme): pre-React bootstrap to kill white-flash on dark galleries (#358)
Opening a gallery with a dark theme briefly painted a white background between the initial HTML render and React applying the per-event theme. The HTML shipped with no theme info, so the first paint used the default (#fafafa) before /gallery/:slug/info resolved. Two-part fix. 1. Inline bootstrap script in index.html runs synchronously before React mounts. Reads the URL, looks up a per-slug background colour from localStorage (gallery-theme-bg-<slug>), and applies it to documentElement immediately. Falls back to #171717 when no cache exists and the OS prefers dark, so first visits with dark OS still land on a dark background. 2. ThemeContext.applyTheme writes the resolved background to localStorage keyed by slug whenever a gallery theme loads. Revisits then hit the bootstrap cache and never see a flash. Added a 200ms transition on html.background-color so the rare cache→API drift (e.g. theme palette changed admin-side since last visit) is a smooth fade instead of a snap. Limitation: first visit on a light-OS device to a dark gallery still flashes once. Killing that case requires a server-rendered theme hint, out of scope for an SPA bootstrap fix. The empty-skeleton-grid part of the same report is already addressed by the 300ms lazy render in #352 — Rekoo-PS just needs to update from v3.32.1-beta.0 to v3.32.2-beta.0+. |
||
|
|
db29d0e278 |
fix(events): coerce expires_in_days to Number before addDays
The "Expires on" preview under the days-after-event input rendered
nonsense dates (e.g. 25.04.2026 + 120 days → 08.01.2095, ~68 years
out). Cause: handleInputChange stores e.target.value verbatim, which is
a string for <input type="number">, so formData.expires_in_days is "120"
not 120. date-fns addDays does:
_date.setDate(_date.getDate() + amount)
When amount is a string, the + is string concatenation:
25 + "120" = "25120". setDate("25120") then sets day-of-month to 25120,
which carries over by ~68 years.
Fix: cast to Number at the call site. The validation/API-payload
codepaths already work because the comparisons at line 330 and the
JSON payload coerce numerically through different paths — only addDays
was actually broken.
The TypeScript type FormData.expires_in_days: number is a lie because
handleInputChange's [field]: e.target.value sets a string regardless.
Tightening that handler is a separate cleanup; this commit only fixes
the visible date bug.
|
||
|
|
88a6c6a7fb |
fix(auth): make /auth/session verify the issuer claim like adminAuth (#350)
Asymmetric JWT verification was causing a /admin/login → /admin/dashboard
→ /admin/login redirect loop for users carrying admin cookies issued
before the iss: 'picpeak-auth' claim was added (commit
|
||
|
|
743086d3cb |
fix(lightbox): smooth carousel swipe + drop instructional hint (#348)
Two fixes for discussion #348. Carousel-style swipe The lightbox previously snapped to the next photo on swipe, then showed a loading spinner while the new image fetched — choppy compared with the reference video the reporter shared. The current photo is now rendered inside a 3-slide track (prev/current/next). As the finger drags, the track follows; on release the track animates to the neighbouring slot or springs back if the gesture didn't pass the threshold. Because the prev/next AuthenticatedImages render up front, the browser starts fetching them while the user is still on the current photo, so there's no loader flash on commit. - Phase machine ('idle' | 'dragging' | 'committing' | 'springing') drives the track's transform/transition. Commit + spring use a 280ms cubic-bezier ease. - Percentage-based transforms avoid measuring container width before the first paint. Commit threshold (read from the ref on demand) is max(60px, 20% of width) OR a fast flick (>0.5 px/ms with at least 40px of movement). - transitionend advances currentIndex with wrap-around and resets the track in one batch — slot contents rotate and the track snaps from the commit position back to centered with transition: none, so the visible image stays put. No flicker. - Vertical-cancel (>24px dy) abandons the drag and springs back so the user keeps the gesture they intended. - touch-action: none on the carousel container stops the browser fighting us with edge-swipe back navigation and native pinch-zoom. - Pinch starting mid-drag springs the track back smoothly so the image doesn't jerk under the second finger. - onTouchCancel covers system-interrupted gestures (incoming call etc). - dragX === 0 short-circuits to 'idle' instead of 'springing' so taps don't get stuck waiting for a transitionend that never fires. - Neighbour slides use a simplified AuthenticatedImage render (no canvas/fragment-grid pipeline) since they're only on screen during the swipe; the current slide keeps the full protection chain. - Neighbour videos render their thumbnail rather than spinning up a VideoPlayer. When the *current* photo is a video, the carousel is bypassed entirely — single VideoPlayer + no swipe handlers — because sliding a video element during a drag is awkward and adds nothing. - Removed the now-redundant imageLoaded state + spinner; AuthenticatedImage already shows a placeholder while loading. Keyboard arrows and the on-screen Prev/Next buttons still snap (no animation) — animating them would have required input queuing for fast double-presses, and the request was specifically about swipe. "Swipe to navigate" hint Removed the mobile-only overlay text. Swipe is universal in image viewers; the instruction read like training wheels and competed with the photo for attention. |
||
|
|
d9d81372b8 |
fix(gallery): lazy-render skeleton grid for fast loads (#321 follow-up)
The gallery loading skeleton now renders the header bars immediately but delays the 12-tile placeholder grid by 300ms. Galleries that load quickly (the common case) never flash the empty grid before the real photos render — addressing the follow-up reported on #321 — while slower loads still get a placeholder so the page doesn't sit blank. |
||
|
|
a5b20ca3fe |
fix(events): server-side search/pagination to remove first-100 cap (#346)
Counters and search on Admin → Events were bounded to the first 100 rows
returned from /admin/events?page=1&limit=100, so on instances with more
events the totals were wrong and search couldn't find anything outside
that window. The dashboard's expiring list had the same first-100 issue.
Backend
- adminEvents.js: extend search to include customer_email so the column
shown in the table is actually queryable.
- adminDashboard.js: add totalEvents to /dashboard/stats so the events
page can render an accurate "All (N)" / Total Events counter without
walking the full table on the client.
Frontend
- events.service.ts: getEvents() now accepts search + the full status
enum (active|inactive|archived|draft|expiring); response type matches
the actual {events, pagination} shape.
- admin.service.ts: DashboardStats gains totalEvents.
- EventsListPage.tsx: rewired around server-side pagination, status
filter, and 300ms-debounced search; Prev/Next + range/page indicator
below the table; placeholderData keeps the previous page visible
during fetches; stat cards and "All (N)" pull from /dashboard/stats so
totals stay accurate regardless of the visible page; archive/delete
invalidates dashboard-stats so cards refresh.
- AdminDashboard.tsx: expiring list now fetches getEvents(1, 5,
'expiring') directly instead of slicing the first 100 client-side. As
a side effect the dashboard's "expiring" definition now matches the
backend (was excluding events expiring within the next 24h).
|
||
|
|
0faf9b3281 |
docs: move documentation to docs.picpeak.app, drop in-repo copies
The full documentation now lives at https://docs.picpeak.app — built from the picpeak-docs Nextra repo. The README, SIMPLE_SETUP, and the v1 OpenAPI generation flow all point there now. Removed (now living at docs.picpeak.app): - DEPLOYMENT_GUIDE.md (root-level — content covered by docs.picpeak.app/deployment) - docs/ADMIN_SETUP_GUIDE.md - docs/JWT_SECRET_MIGRATION.md - docs/SECURITY_BEST_PRACTICES.md - docs/admin-api-quickstart.md → docs.picpeak.app/api - docs/nginx-fix.md → docs.picpeak.app/deployment/reverse-proxy - docs/openapi.json, docs/openapi.yaml → still generated locally as a build artifact (now gitignored), synced into picpeak-docs by scripts/sync-api-docs.sh - docs/picpeak-admin-api.openapi.yaml → ditto Kept: - docs/*.png (logo + screenshots — README still img-tags these) Updated: - README.md — replaced six in-repo doc links with docs.picpeak.app pointers, restructured the Documentation section as a curated link list to the new site - SIMPLE_SETUP.md — single deployment-guide link redirected - .gitignore — docs/openapi.{json,yaml} are now build artifacts, not tracked - backend/src/routes/v1/events.js — comment clarifies the OpenAPI flow |
||
|
|
1e69d5ff71 |
feat(webhooks): enrich event.* payloads with customer contact + share_token (#341)
The event.published webhook reporter wired into n8n to send WhatsApp
gallery links was missing the data needed to actually message the
customer — only event_name + share_url were in the payload, no
customer_name / customer_email / customer_phone, and no bare share
token to construct alternate URLs.
Adds a single canonical event subject helper (webhookService.buildEventSubject)
so every event.* webhook returns the same shape:
{ id, slug, event_name, event_type, event_date,
share_url, share_token,
customer_name, customer_email, customer_phone }
Fields the caller does not have in scope come back as null — keys are
always present so receivers do not have to distinguish "field missing"
from "field null". Pure addition: existing receivers continue to work,
existing templates ${data.event.event_name} keep working, and new
templates can now reference ${data.event.customer_phone} etc.
Wired into all five firing sites:
- routes/events.js — public event create (created + published)
- routes/adminEvents.js — admin create + draft→publish
- routes/v1/events.js — public v1 API (created + published)
- services/expirationChecker.js — event.expired (extra: expires_at)
- services/archiveService.js — event.archived (extra: archive_path)
PII surface area widens (customer email/phone now flow to webhook
receivers), so:
- Settings → Webhooks UI gets an amber Callout above the create form
warning admins to only point webhooks at receivers they trust.
- Docs page updated with the new payload sample, the always-present
null contract, and a Callout warning.
Verified end-to-end against the local dev webhook receiver — delivered
payload contains all 10 fields. webhookDelivery integration suite
remains 8/8 green.
|
||
|
|
f171f6b974 |
refactor(settings): grouped left-rail nav replaces overflowing tab bar
The Settings page packed 13 tab buttons into a single horizontal nav that overflowed even at 1440px — items wrapped or got clipped, and "Webhooks" disappeared off the right edge entirely. Pattern was the right call at 5 tabs and broken at 13. Replaces the flat row with the macOS Settings / Stripe / GitHub pattern: - **Desktop (lg+)**: 220px sticky left rail with five labelled groups — General, Display, Privacy & Security, Integrations, System — and a lucide icon next to every item. Active state uses the existing primary token. Adds a section header on the right pane that echoes the active item so the context is obvious after a switch. - **Mobile (< lg)**: native <select> with <optgroup> per category. One tap to switch, no horizontal scroll, screen-reader friendly. Categories chosen to be balanced (avg 2.6 items/group) and to map to how admins actually think about these settings rather than alphabetical or insertion order. Ports the existing inline-fallback i18n pattern for the new group labels. |
||
|
|
42a7ae4be8 |
fix(lightbox): mobile toolbar clipping + iOS safe-area + viewport-fit (#336)
When feedback was enabled the lightbox bottom toolbar packed counter + zoom + download + like + 5-star + comments into a single row that overflowed the viewport on iPhone-class widths, putting the rating stars under the screen edge and below the iOS home indicator. Changes: - Bottom toolbar now uses flex-wrap with reduced gap/padding on mobile, so all controls fit (375px viewport: max-right 363 < 375; 390px: max-right 378 < 390; 393px: max-right 393 < 393). - pb computed as max(0.75rem, env(safe-area-inset-bottom)) so the row sits above the iOS home indicator on devices with a gesture bar. - Close button top/right now use max(1rem, env(safe-area-inset-*)) so it doesn't disappear under the notch / dynamic island. - "Swipe to navigate" hint moved from bottom-20 to bottom-40 so it clears the now-taller wrapped toolbar. - index.html viewport meta gains viewport-fit=cover to enable env(safe-area-inset-*) on iOS Safari. Verified in mobile emulation across iPhone SE (375x667), iPhone 13/14 (390x844), iPhone 14 Pro (393x852) portrait, and 14 Pro landscape (852x393) — toolbar fits, photo centered, no clipping. |
||
|
|
fcddfe094b |
fix(gallery): use ref for swipe-start to avoid stale-closure miss (#332)
Found via real-browser verification: with useState the prior commit's handleTouchEnd captures swipeStart from its render closure, so when touchstart and touchend fire inside the same React batch (fast swipe, synthetic events, or a tight render cycle) the end handler reads the stale null and skips navigation. useRef sidesteps the closure entirely and is the right primitive for cross-event scratchpad state anyway. Verified in a 4-photo gallery on mobile-emulation (390x844 touch): - left swipe (-200px) advances 1/4 → 2/4 - right swipe (+200px) returns 2/4 → 1/4 - 20px swipe (under threshold) does not navigate - vertical swipe (dy 300, dx 20) does not navigate |
||
|
|
5275621fcd |
fix(share): OG/Twitter-card metadata for gallery share URLs (#333)
WhatsApp / Slack / Facebook / Twitter previews showed nothing useful for shared gallery links — the SPA's stub index.html has no OG tags and the meta-injection in DynamicFavicon happens at runtime, which crawlers never see (they don't execute JS). Add a backend OG handler at /og/gallery/:slug that returns minimal HTML with proper og:* and twitter:* meta sourced from the event row + branding settings (event name, formatted date, welcome_message excerpt as description, configured logo as the preview image, FRONTEND_URL-based canonical). Honours slug redirects so renamed galleries still get rich previews. Wire crawler detection in both nginx configs (production and dev) — UA match against the standard list (facebookexternalhit, WhatsApp, Slackbot, Twitterbot, Discordbot, LinkedInBot, etc.) triggers an internal rewrite to /og/gallery/:slug, while humans fall through to the SPA via try_files. The OG endpoint is also wired into the native-install SPA fallback in server.js for setups that bypass nginx. The OG image is intentionally the brand logo, not a gallery photo — crawlers fetch it without auth, and password-protected gallery photos must not leak via share previews. |
||
|
|
4c8eba0cb4 |
fix(gallery): single-finger swipe nav in mobile lightbox (#332)
The lightbox showed a "Swipe to navigate" hint on mobile, but the touch handlers only implemented pinch-to-zoom (2-finger). Single-finger swipe fell through and the user could only navigate with the on-screen arrows. Add a 1-finger swipe detector: track the initial touch position, and on touchEnd compute deltaX/deltaY/duration. Trigger goToPrevious / goToNext when the horizontal swipe exceeds 50px, dominates over vertical motion (1.2x), and completes within 600ms. Suppressed while zoomed in so the user can pan the image instead. |
||
|
|
4c73d228ed |
fix(events): show customer phone in event details view (#331)
The phone field added in #322 was wired into the edit form but never rendered in the read-only event-info panel, so admins could only see the number while editing. Add a phone row gated on event_phone_field_enabled (same toggle the form uses), and tighten the Event type so customer_phone is no longer accessed via `(event as any)`. |
||
|
|
e232f9f2cf |
fix(backup): incremental backups against S3 + jsonb stats parsing
Three fixes uncovered while bringing the backup-s3 integration suite to 12/12 against MinIO + Postgres: - backupService.getDatabaseBackupInfo: pg's jsonb driver auto-parses `statistics` / `table_checksums` to objects; the old JSON.parse() then threw "[object Object]" is not valid JSON and the manifest dropped database info silently. Accept both string and object inputs. - backupService.runBackup: incremental path called backupManifest.loadManifest() with an s3:// URI directly, which falls through to fs.readFile() and ENOENTs — every "incremental" backup silently downgraded to a full one. Added loadManifestFromAnywhere() helper that downloads s3:// to a tmp file before delegating. - backupManifest.generateIncrementalManifest: attached the `incremental` section AFTER generateManifest() had already stamped verification.total_checksum, so every incremental manifest failed validateManifest() on read-back. Recompute the checksum after. Test side: updated assertions to the current manifest shape (`incremental.changes.modified_files_count`), Number()-coerce bigint columns from pg, and gate the logger mock on UNMOCK_LOGGER for diagnosing similar silent-failure modes in the future. |
||
|
|
ab4095f592 |
fix(backup): cron schedule mapping + manifest format detection + bigint coerce
Three pre-existing bugs surfaced by re-running the backup-s3 integration
suite. backup-s3 went 0/12 → 7/12 (storage-refactor session bootstrap
fixes) → 10/12 with this commit.
1. Backup service crashes on backend startup with
`TypeError: Cannot read properties of undefined (reading 'replace')`
from node-cron's expression parser.
Root cause: `backup_schedule` stores a UI label like "weekly", while
`backup_schedule_cron` stores the actual cron expression. Startup
code read the label and passed it straight to cron.schedule() —
"weekly" is not a cron expression.
Fix in startBackupService(): read backup_schedule_cron first; fall
back to mapping known labels (hourly/daily/weekly/monthly) to cron
expressions; back-compat for deployments that wrote a cron expression
into the legacy backup_schedule field.
2. Backup manifest retrieval fails with
`SyntaxError: Unexpected token 'a', "applicatio"...` when the
manifest format is YAML.
Root cause: getBackupManifest() downloads the s3:// manifest to a
tmp file hardcoded as `manifest-N.json`. loadManifest() then
detects format from extension only — sees .json, runs JSON.parse on
YAML content (which starts with "application: …"), fails.
Fix in backupManifest.loadManifest(): detect format from BOTH the
extension AND the content's first non-whitespace character. JSON
starts with { or [; anything else falls through to yaml.load.
Backwards compatible — extension is still authoritative when present
AND content matches.
3. Test assertion `expect(backupRun.total_size_bytes).toBeGreaterThan(0)`
fails with "received value must be a number or bigint" because pg
driver returns bigint columns as strings. Coerce via Number() in
the test.
Remaining 2 failures (out of scope here, both are spec-level drift):
- "should include database backup" expects the runBackup() flow to
upload the database backup file at S3 key `database/db-backup.sql`.
Current implementation reads db backup metadata for the manifest but
does not upload the file itself. Missing feature, not a test bug.
- "should only upload changed files" expects manifest.incremental.
modified_files_count. Implementation writes backupType: 'incremental'
on the run row but no per-run incremental subobject in the manifest.
Field shape mismatch.
|
||
|
|
446d80a4cc |
feat: presigned download UI + S3 prefix walker auto-importer (follow-ups)
Closes the user-facing surface for the two #328 follow-ups previously landed in code form (presigned route + S3 mode notes), plus the schema migration that backs both #328 and #327 follow-ups. Migration 083 - events.allow_presigned_download — per-event opt-in for the presigned-URL "Download All" path. Off by default because it bypasses watermarks; admins flip it knowingly. Mutually exclusive with watermark_downloads. - webhooks.filter (jsonb default {}) — dot-path equality predicate evaluated at fire time. Empty object = no filter, fire always. Backs the filter logic that shipped with #327. - webhooks.template (text nullable) — optional ${dot.path} string substitution applied at delivery time. NULL = use the default JSON envelope (back-compat). Backs the template logic from #327. S3 prefix walker (services/s3AutoImporter.js) - Replaces the chokidar file-watcher in S3 mode (where there's no inotify equivalent on remote objects). - Polls every active event's S3 prefix every 5 min by default (STORAGE_AUTO_IMPORT_INTERVAL_MS overridable). - Eventual-consistency gate: an object is only imported after it's been seen for two consecutive polls. Avoids flapping when S3 returns a freshly-uploaded object that disappears on the next list (a documented S3 behavior on certain backends). - Skips generated artifacts (thumb_*, hero_*, dot-files). - Inserts photos rows + fires photo.uploaded webhooks the same way the local fileWatcher does. - Opt-in via STORAGE_AUTO_IMPORT=true. Off by default because it adds API call cost. EventDetailsPage UI (frontend) - Round D queryKey alignment for #325 dedup — replaces useQuery on publicSettingsService with the shared usePublicSettings() hook so the page joins the same React Query cache as every other consumer. - Per-event "Allow direct S3 download (no watermark, S3 mode only)" toggle in Download Protection. Disabled when watermark_downloads is on; tooltip explains the bandwidth/watermark trade-off. Toggling watermark_downloads on automatically clears allow_presigned_download to keep the two mutually exclusive in the UI. Verified live against MinIO - Presigned: GET /api/gallery/.../download-all → 302 with Location: http://minio:9000/...?X-Amz-Signature=...&X-Amz-Expires=300. Following the URL inside the docker network → HTTP 200, valid PK ZIP archive containing the photo. - Auto-importer: dropped a file via `mc cp` directly into the bucket; watcher imported it after 2 polls; webhook subscribed to photo.uploaded fired with source=s3-auto-import; receiver got POST with valid HMAC, status=success, 3ms latency. |
||
|
|
c488f481ca |
feat: outbound webhooks for event/photo lifecycle (#327)
PicPeak POSTs lifecycle notifications to admin-configured URLs. Each delivery is signed HMAC-SHA256 in the X-PicPeak-Signature header. Verified end-to-end: 1/1 Playwright spec, 8/8 backend integration tests, full UI click-through via Chrome DevTools. Schema (migration 082) - webhooks: id, name, url, secret (plaintext — required to compute HMAC for every outbound POST), secret_preview, events[], active, filter, template, created_by, timestamps, last_success_at/last_failure_at. - webhook_deliveries: webhook_id (FK CASCADE), event_type, payload, attempt_count, status (pending|success|failed), response_status, response_body (truncated to 1KB), latency_ms, next_retry_at, last_error, created_at, completed_at. Composite index (status, next_retry_at) serves the worker's hot-path query. Service + worker - webhookService.fire(eventType, data) — non-throwing entry point used by lifecycle hooks. Looks up active webhooks subscribed to the event and applies their per-webhook filter (dot-path equality predicate) before enqueueing one webhook_deliveries row per match. Filter and template logic ship in this commit; admin surfaces in the follow-up. - webhookDeliveryWorker — setInterval(5s) poller; fetches up to 5 pending rows; per delivery: re-validates URL via networkValidation (DNS-rebinding mitigation, opt-out via WEBHOOK_ALLOW_PRIVATE_URLS), signs body with HMAC-SHA256, POSTs with 10s timeout, records outcome. Backoff schedule: 1m → 5m → 30m → 2h → 12h, max 5 attempts. Response body truncated to 1KB before storage. If a webhook has a template, the rendered string replaces the JSON envelope as the request body (signature is computed over the bytes actually sent). Lifecycle wiring - adminEvents.js POST /events → event.created (+ event.published when not draft); POST /:id/publish → event.published. - routes/events.js (legacy public POST) → event.created + event.published. - routes/v1/events.js (#322 API) → event.created + event.published on create, photo.uploaded on photo POST. - archiveService.archiveEvent() → event.archived. Per-photo photo.deleted intentionally NOT fired during cascade — receivers infer from event.archived to avoid flooding (issue spec). - expirationChecker.handleExpiredEvent() → event.expired BEFORE the cascading archive (so receivers see expired→archived in order). - adminPhotos.js — photo.uploaded on each batch row, photo.deleted on single + bulk delete. - photoProcessor.js — photo.uploaded for guest uploads + auto-import (covers all entry paths). - fileWatcher.js — photo.uploaded on add, photo.deleted on unlink (local mode only). Admin endpoints (mirrors adminApiTokens.js pattern) - /api/admin/webhooks: GET list, POST create (returns plaintext secret exactly once), GET :id, PUT :id, DELETE :id, POST :id/test (synthetic fire), GET :id/deliveries (paginated, filter by status), GET :id/deliveries/:deliveryId, POST :id/deliveries/:deliveryId/replay. Frontend - Settings → Webhooks tab (mirrors API Tokens layout): name + URL + event checkboxes + "Advanced" expander for filter (JSON) and template. Plaintext secret shown once on creation with a Copy button. Active/ Disabled toggle button per row. - /admin/webhooks/:id/deliveries — operational debug surface. Table with timestamp/event/status/attempts/HTTP/latency. Status filter chips (all/pending/success/failed). Row click → slide-over with payload + signature + response body. Replay button on failed rows. Send-test-event dialog. Auto-refresh every 10s. Dev infrastructure - dev/webhook-receiver/ — tiny node:alpine HTTP server (~100 LOC) that records every POST to an in-memory ring buffer. Exposes GET /requests for the E2E spec to assert deliveries landed with the right HMAC. Sibling pattern to MinIO. Reachable from the backend at http://webhook-receiver:8888 inside the picpeak network. Tests - backend/__tests__/integration/webhookDelivery.test.js (8/8) — signature verification, headers, retry/backoff, max-attempts → failed, response truncation, disabled-mid-flight, SSRF block, start/stop idempotency. - tests/e2e/webhooks-roundtrip.spec.ts (1/1) — create webhook → trigger event.published → assert receiver got POST with valid HMAC → visit deliveries page → row visible with status=success → API test event → API replay → disable webhook → assert no new delivery. Docs - README §"Webhooks" — event catalog, payload shape, HMAC verification in Node + Python + bash, retry semantics, SSRF protection. - .env.example — WEBHOOK_ALLOW_PRIVATE_URLS, WEBHOOK_DELIVERY_INTERVAL_MS, WEBHOOK_DELIVERY_CONCURRENCY, WEBHOOK_HTTP_TIMEOUT_MS, WEBHOOK_MAX_ATTEMPTS. Out of scope for v1 (per issue): webhook templates' code-eval (the ${dot.path} substitution that ships is pure string replacement, no expression engine — see follow-up commit), per-webhook rate limiting beyond the global concurrency cap, synchronous "ask before delete" webhooks. Spanning files - App.tsx pulls in this commit with both the AnalyticsBootstrap (#325 dedup) and the WebhookDeliveriesPage route registration. Splitting via git add -p was forfeit for sanity; the single 92-line diff is honest about both contributions. - adminEvents.js diff bundles the webhook fires AND the allow_presigned_download field plumbing (#328 follow-up). Same reasoning. - The new webhookService/Worker/adminWebhooks files include the filter and template logic from the follow-up — they were authored in one pass; splitting them post-hoc would have produced fragile partial files. The follow-up commit covers the migration and the UI for these. |
||
|
|
1b717ce5ed |
feat: native S3 storage backend (#328) + presigned download follow-up
Lets PicPeak write photos, thumbnails, hero images, watermarks, and archive zips to any S3-compatible bucket (AWS S3, MinIO, Cloudflare R2, Backblaze B2, Wasabi, DigitalOcean Spaces) instead of the local filesystem. Selected via STORAGE_BACKEND=local|s3. Architecture - backend/src/services/storage/StorageBackend.js — abstract interface (put/get/exists/stat/delete/list/copy/rename/signedUrl/putFromFile/ getToFile) — typedef-only, documents the contract. - LocalFsStorage.js — wraps fs with atomic-write-via-tmp-rename, path traversal protection, list-as-walker. - S3StorageBackend.js — thin wrapper around the existing S3StorageAdapter (used by backupService) mapping it onto the canonical interface; supports optional STORAGE_S3_PREFIX namespace. - index.js — factory selected by STORAGE_BACKEND with startup ping (HEADs sentinel key on S3, fs.stat on local) so misconfig fails fast before the first request. Consumer refactors (~12 services + routes), each parametrized over the abstraction: - imageProcessor / videoProcessor — pipe Sharp/ffmpeg output through storage.put; expose withLocalCopy() helper for S3-mode regeneration paths that need a local file for sharp/ffmpeg. - archiveService / downloadZipService — finalize zip in tmp dir, then storage.putFromFile. Atomic-rename pattern preserved on local; S3 emulates via copy + delete (worker prunes orphaned .tmp.* on startup). - photoProcessor / photoReplacementService / adminPhotos upload+delete / routes/v1/events.js POST /events/:id/photos / routes/events.js — every upload path now goes storage.putFromFile(temp) → unlink temp. - gallery.js bulk-download (cached + on-the-fly + selected) — managed photos via storage.get, external-mode unchanged. - protectedImages / secureImages / photoResolver — read via storage.get; resolvePhotoStorageKey returns the canonical key. - watermarkService / watermarkGeneratorService — persistent watermarks via storage.put. - fileWatcher — bails out with a clear log warning when STORAGE_BACKEND=s3 (chokidar can't watch S3); auto-import lands via the S3 prefix walker introduced in the follow-up commit. - expirationChecker — small touch (event.expired webhook fire from #327 shipping in the next commit). Migration tooling - backend/scripts/migrate-storage.js — one-shot --dry-run capable script that walks photos.path, thumbnail_path, hero_path, watermark_path and events.archive_path/download_zip_path; streams local → S3; sha256 size-match skip for idempotent re-run; failures CSV. Presigned-URL "Download All" (#328 follow-up shipped in this commit) - routes/gallery.js — when STORAGE_BACKEND=s3 + event.allow_presigned_download + downloads enabled + watermark NOT enabled, /download-all returns a 302 redirect to a 5-minute presigned S3 URL. Per-event opt-in surface ships in the next commit's UI. Tests - backend/__tests__/integration/storageBackend.test.js — parametrized contract suite running against BOTH LocalFs AND MinIO (18 tests, both backends — 36 cases total). - backend/__tests__/integration/imageProcessor.storage.test.js — same parametrized pattern for the image processor (10 tests × 2 backends). - backend/__tests__/integration/backup-s3.test.js — bootstrap fix: drop the redundant initDb() (001_init handles it) and remove schema-drift in configureS3Backup (app_settings has no created_at anymore and the unique constraint is on setting_key alone, not composite). 0/12 → 7/12 (5 remaining are unrelated assertion drift). - backend/src/services/photoResolver.js — mixed-source events (reference mode with managed-uploaded photos) now fall back to managed when external_relpath is missing instead of throwing. - tests/e2e/s3-storage-roundtrip.spec.ts — Playwright spec that auto-skips against local backend; full upload → serve → delete round-trip when run against an S3-mode backend. Server wiring (server.js) - initStorage() called after database init, before rate limiters. - This commit's diff also includes the webhook delivery worker startup and the S3 auto-importer startup. Those features ship in the next two commits — co-located here for one bisectable diff per file. Docs + ops - README §"Storage Backends" — capability matrix, switching playbook, IAM policy snippet, MinIO/R2/B2 examples. - README §"Webhooks" — also added here (full diff bundled). - .env.example — STORAGE_BACKEND + STORAGE_S3_* + STORAGE_AUTO_IMPORT documented; WEBHOOK_* added in the same diff. - .gitignore — re-anchor the existing `storage/` rule to `/storage/` so backend/src/services/storage/ (the new abstraction code) is trackable. The runtime ./storage/ data dir stays ignored. Out of scope for v1 (per the issue): presigned URLs for individual photo display (always streamed for protection middleware), CDN integration, hybrid hot/cold tiers, S3 → local migration, multi-bucket per-event. |
||
|
|
3d4ae4d7e9 |
feat(frontend): dedupe /public/settings via shared usePublicSettings hook (#325)
Pre-dedup: 7 calls to /api/public/settings on a single /admin/login page load — 4 from raw-fetch consumers + 3 from React Query consumers using inconsistent queryKeys. Captured live in Chrome DevTools. Post-dedup: 1 call. Verified by tests/e2e/public-settings-dedup.spec.ts. Adds: - frontend/src/hooks/usePublicSettings.ts — single React Query hook, 60s staleTime, queryKey ['public-settings']. Vitest with mocked api proves multi-mount dedup. - Extended PublicSettings interface with seo_meta_* fields used by RobotsMetaTags and branding_logo_* fields used by GalleryView/AdminHeader. Migrates 19 call sites across 4 risk-ordered rounds: - Round A (high fan-in): GlobalThemeProvider, MaintenanceContext (with refetchInterval to preserve maintenance polling), MaintenanceWrapper (drops the now-redundant per-route ping; axios interceptor already handles 503), AdminHeader. - Round B (gallery/login): GalleryView, GalleryPage, ClientAccessPage, AdminLoginPage, MaintenanceMode. - Round C (decorative): RobotsMetaTags, DynamicFavicon, CMSContentBlock, ReCaptcha, useWatermarkSettings (rips out raw fetch + local state), LegalPage. - Round D (queryKey alignment): useLocalizedDate, UserPhotoUpload, CreateEventPage. EventDetailsPage Round D ships in the follow-up commit that adds presigned-download UI on the same page. App.tsx (AnalyticsBootstrap) and EventDetailsPage are deferred to later commits — both files mix #325 changes with backend feature work. |
||
|
|
46bc894d91 |
docs: add Buy Me a Coffee badge + Support section
Adds a yellow Buy Me a Coffee badge to the header alongside the existing License/Docker/Node/React badges, plus a small "Support the Project" section above Acknowledgments with the standard BMC button image. Also adds a link in the inline nav row at the top of the README so first-time visitors can find it. Link: https://buymeacoffee.com/theluap Lightweight, opt-in support — explicitly notes that starring, sharing, filing good bug reports, and opening PRs are equally welcome ways to help if money isn't in the budget. |
||
|
|
038e84cae7 |
fix: dedupe parallel admin 401 redirects to /admin/login
Visiting /admin/dashboard while logged out caused a navigation storm:
the dashboard fires ~7 /api/admin/* queries on mount, each returns 401,
each axios interceptor call did `window.location.href = '/admin/login'`.
The path-based guard `currentPath.includes('/admin/login')` reads
`location.pathname` *synchronously* — but `location.href = …` is async,
so all 7 parallel handlers saw the still-old pathname and each fired a
fresh navigation. The browser logged 6+ ERR_ABORTED entries and the user
saw a flicker storm. Same shape would bite any admin page that fans out
queries on mount.
Add a module-level `adminLoginRedirectPending` flag set the moment we
kick off the first redirect; subsequent 401s in the same tick see it
and skip. Single navigation, clean transition to login.
Smoke spec 10-admin-redirect-loop locks the regression in by sampling
the URL across 5 ticks — if any tick lands somewhere other than
/admin/login, the spec fails.
|
||
|
|
2eead52319 |
fix: theme picker buttons no longer submit the parent form (#326)
Every <button> inside ThemeCustomizerEnhanced was bare — no `type`
attribute, defaulting to `type="submit"`. Inside CreateEventPage's
<form onSubmit={handleSubmit}>, that turned every theme/layout/header/
divider/control/colour-mode/CSS-template click into a form submission.
When the form was empty, validation killed the submit silently — that
showed up earlier as #317.2 ("theme picker unclickable").
When the form was filled (event_name set, etc.), validation passed,
`createMutation.mutate(payload)` ran, and the user was navigated to a
freshly-created event they never asked for — #326's reported symptom.
Fix: add `type="button"` to all 9 unmarked <button>s in the customizer.
Also covered by smoke spec 09-create-event-no-instant-submit which fills
the form, clicks Modern Masonry, and asserts the URL stays on
/admin/events/new and the events count is unchanged.
|
||
|
|
808b15bafb |
feat: public v1 API + token management + OpenAPI docs (#322)
Adds a long-lived bearer-token mechanism + scoped REST surface designed
for n8n-style automation: create a gallery, upload photos, fetch the
share URL — all via documented HTTPS endpoints instead of poking at the
admin UI's internal routes.
API
- Migration 081 adds `api_tokens` (hashed_token, scopes, owner FK,
last_used/expires/revoked timestamps).
- New apiTokenAuth middleware: parses `Authorization: Bearer pp_live_…`,
resolves to the owner admin user, attaches `req.admin` so existing
permission decorators (events.create etc.) still work. Token-level
scope check (read/write/admin) layers on top as defence in depth —
a leaked read-only token cannot mutate even if its owner is super_admin.
- adminApiTokens route exposes list/create/revoke for admins (cookie-
authed). Plaintext token is returned exactly once on creation.
- v1 surface mounted at /api/v1: POST/GET /events, GET /events/:id,
POST /events/:id/photos (multipart, single file), GET
/events/:id/share-link. Each endpoint annotated with @openapi JSDoc.
Documentation
- swagger-jsdoc + swagger-ui-express produce a live spec at
/api/openapi.json and a Swagger UI at /api/docs (admin-gated).
- backend/scripts/generate-openapi.js writes docs/openapi.{json,yaml}
to the repo so the spec is versioned.
- scripts/sync-api-docs.sh runs in pre-push: regenerates the spec and
copies it into the picpeak-docs Nextra site at app/api/. Writes only,
never commits or pushes the docs repo (PUSH_SKIP_DOCS=1 to bypass).
Frontend
- New Settings → API Tokens tab: generate, list, revoke. Plaintext
tokens are shown once with a copy-to-clipboard control.
|
||
|
|
be6cb28c80 |
feat: optional customer phone field gated by global toggle (#322)
Adds a `customer_phone` column on events plus an `event_phone_field_enabled` admin setting (default off) that surfaces the input in the create-event and event-detail forms. Designed for downstream automation tooling — once exposed via the upcoming public API, n8n / similar can pick it up to deliver gallery links over WhatsApp, SMS, etc. - Migration 080 adds the column + seeds the setting as false. Existing deployments see no UI change unless the admin opts in via Settings → Events. - Backend strips the field server-side when the toggle is off (defence in depth against form bypass). - Frontend renders the input only when the public-settings flag is true; always optional even then. - publicSettings + EventSettings types extended; CreateEventPage and EventDetailsPage wired to read the toggle and submit the value. |
||
|
|
4f77905b87 |
feat: customisable 404 + gallery-not-found pages via CMS (#324)
The 404 catch-all and the "gallery not found" branches in GalleryPage
were hard-coded English strings on a default-themed background — the
one place where a white-labelled deployment leaked the PicPeak default
look. Pluggable now via the existing CMS Pages mechanism.
Backend:
- Seed two new default CMS pages: `not-found` and `gallery-not-found`,
with sensible English/German copy admins can edit in /admin/cms.
- Add `cms_pages.logo_url` (nullable) for per-page logo override; online
migration on existing deployments. Null falls back to the global
branding logo.
- New per-page logo upload (POST /api/admin/cms/pages/:slug/logo) +
clear endpoint (DELETE …/logo). Reuses the existing /uploads/logos
storage location with a `cms-<slug>-` filename prefix.
- adminCMS PUT now accepts logo_url; publicCMS GET returns it.
Frontend:
- New <CMSContentBlock slug fallback> component renders the CMS page in
the standard branded shell (logo precedence: page → branding → bundled
default), with DOMPurified content and footer/legal links.
- App.tsx: `path="*"` catch-all routes through CMSContentBlock("not-found").
- GalleryPage: collapses the two "gallery not found" branches (invalid
identifier + infoError archived/missing) into a single
CMSContentBlock("gallery-not-found"), so admins can edit one source
of truth.
- Admin CMS Page editor gains an "Upload Logo / Use site default"
control per page; falls back to the page's own English title in the
page list when no `legal.<slug>` translation is registered.
|
||
|
|
b63a8774c4 |
fix: theme-preset match loop ignores extra fields like logoUrl (#323)
The "which preset does this saved theme match?" loop in BrandingPage and CreateEventPage was doing a full JSON.stringify equality on preset.config vs the loaded theme. The previous #323 logo-preservation work means the saved theme legitimately carries a `logoUrl` (and any other fields the parent maintains), so the equality check would never match and the preset summary fell back to "Custom Theme" / Classic Grid even when the saved theme was structurally Dark Modern, etc. Compare only on the preset's own keys instead. Surfaced by the new smoke spec 07-branding-default-on-create-event which would otherwise pass green against the broken state. |
||
|
|
793e410554 |
fix: floor password_changed_at when comparing against JWT iat
JWT `iat` has 1-second resolution; `password_changed_at` is stored with sub-second precision. The previous comparison rejected tokens whose iat fell in the same wall-clock second as a password change — e.g. a token issued by an immediate re-login after a password reset, or by any script-driven flow that resets and logs in in quick succession. Floor the stored timestamp to whole seconds before comparing. Caught while wiring up the local E2E suite: the seeder needed a "set password_changed_at 10 s in the past" hack to avoid this race; with the fix in place that hack is gone and the suite is naturally deterministic. |
||
|
|
8d0fb8e157 |
chore: expose pid + uptime on /health for crash-detection monitors
Adds `pid` and `uptime` fields to the /health response so external monitors (and the local E2E watchdog) can detect a silent process restart between two checks — e.g. an unhandled rejection that crashes Node and Docker quietly relaunches the container. Also adds .gitignore patterns for a local-only E2E suite that lives in tests/e2e/local/ on individual machines and is never pushed. |
||
|
|
822be9a9b2 |
fix: theme save without Live Preview, Branding default on new events, gallery loading flicker (#323, #321)
#323-A — Branding colour changes weren't persisting unless "Apply changes immediately (Live Preview)" was checked. ThemeCustomizerEnhanced was gating its `onChange` callback on `isPreviewMode`, but the parent BrandingPage already gates global `setTheme()` on its own copy of that flag — so the customizer's gate was double-gating and silently dropped the new values from the parent state that Save reads from. Always propagate `onChange`; let parents decide what's "live". Removed the now no-op `isPreviewMode` prop and dropped the unused passers. #323-B — Default theme set in Branding wasn't applied to new events. CreateEventPage only inherited the event-type's recommended preset, with 'default' falling back to Classic Grid. Now reads `settings.theme_config` on first load and uses it as the form's starting theme; the event-type effect skips the generic 'default' so the Branding default sticks for event types like "Other". #321 — Visitors saw four sequential render states when opening a gallery (full-page "Loading Gallery" → "publicly accessible — loading photos" card → skeleton grid → real gallery). Extracted the skeleton into a shared <GallerySkeleton/> and used it for both GalleryView's photos- loading state and GalleryPage's gallery-info-loading + public-auto-login phases. The "publicly accessible" interstitial is gone. Net: one continuous skeleton from URL open until real photos render. |
||
|
|
6cfff6f6a6 |
fix: address bugs and feature requests from discussion #317
- Share link: display and copy now use the absolute URL built from the current origin instead of the relative path stored in events.share_link. Added a Copy Link button to the events list (inline + dropdown). - Detect dev tools default: event creation now reads the global enable_devtools_protection app setting instead of always falling back to the column default; admins who disable it globally get new events with it disabled too. - Require password default: added a global "Require password by default" setting (event_default_require_password, default true), exposed via Settings -> Events. Create-event form initialises from it. - Filter bar: added gallery_show_filter_bar setting and hide the search/ sort row in the public gallery when off, or when the gallery has zero photos (fixes the empty-state UX from the screenshot). - Theme picker unclickable on Create Event: memoised availableEventTypes so its identity is stable. The "auto-apply event-type recommended preset" effect was firing on every render due to the unstable array reference and silently overwriting the user's preset selection ~1ms after each click. - Branding logo disappearing on theme change: handlePresetChange and handleThemeChange no longer wipe the existing logoUrl when a preset config (which carries no logoUrl) is applied; handleSave falls back to brandingSettings.logo_url. themeMutation now invalidates the admin-settings and public-settings caches so saved theme changes appear immediately. |
||
|
|
e4b0f961b7 |
fix: prevent backend crash on archive when admin_email is null (#318)
Archiving an event with no admin_email queued an email_queue row with
recipient_email=null, violating the NOT NULL constraint. The error was
thrown inside the output.on('close') callback (detached from the caller),
becoming an unhandled rejection that crashed Node and dropped admin
sessions on bulk archive.
- Skip queueEmail when event.admin_email is null/empty (admin_email has
been nullable since migration 073).
- Wrap the close handler in try/catch so any post-archive failure logs
instead of crashing the process.
|
||
|
|
e18afd3e6b |
feat: pre-zip download all and photo replacement by name (#312, #313)
Pre-zip downloads: - Generate ZIP in background after photo mutations (upload/delete/watermark change) - Serve cached zip with Content-Length for instant downloads and native progress bar - Falls back to on-the-fly streaming when no cache exists yet - Frontend uses browser-native download when zip is ready (no blob buffering) - New downloadZipService with debounced regeneration and in-memory locking Photo replacement: - Admin upload form gets "Replace existing photos with same name" checkbox - Matches by original_filename (case-insensitive) within the same event - Preserves photo ID, position, feedback, category, and visibility - Updates file, thumbnail, dimensions, EXIF capture date on replacement - Ambiguous matches (multiple photos with same name) skip replacement with warning - New photoReplacementService with findReplacementCandidate and replacePhoto |
||
|
|
094276d3cc |
fix: revert /api prefix in adminPhotos.js to avoid double-prefix
AdminPhotoGrid uses AdminAuthenticatedImage which fetches via Axios (baseURL: /api), so the backend URL must not include /api — Axios adds it. The adminGuests.js /api prefix is correct because its consumer (AuthenticatedImage) uses fetch() with buildResourceUrl(). |
||
|
|
9323befdd9 |
fix: display welcome message in gallery and fix guest thumbnail URLs (#306, #307)
- Render welcome_message in gallery view for all non-fullpage layouts (grid, masonry, carousel, timeline, mosaic) as a centered banner - Add /api prefix to thumbnail/photo URLs in adminGuests.js and adminPhotos.js so they route correctly through Nginx proxy |
||
|
|
dffe057772 |
fix: apply sort direction in gallery view and respect show_feedback_to_guests (#302, #303)
- Gallery now respects the configured sort direction (asc/desc) from default_photo_sort setting instead of using hard-coded directions - Photos endpoint zeroes out feedback fields (like_count, favorite_count, average_rating, comment_count, has_feedback) when show_feedback_to_guests is disabled, while still showing data to admin/client users |
||
|
|
15a8ab41fd |
feat: add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments (#298)
Adds a third value for the COOKIE_SECURE environment variable that decides the cookie Secure flag per-request based on req.secure. This unblocks a common self-hosted setup where the same PicPeak deployment is reachable over both HTTPS (via reverse proxy) and plain HTTP (e.g. LAN access at http://192.168.x.x:3001). Behavior unset - legacy default: follows NODE_ENV (production=true, dev=false) true - always set Secure (unchanged) false - never set Secure (unchanged) auto - NEW: use req.secure per request. In practice this means Secure on HTTPS requests (when X-Forwarded-Proto: https reaches Express via a trusted proxy) and no Secure flag on plain HTTP requests. The existing trust proxy config (`app.set('trust proxy', 'loopback, linklocal, uniquelocal')` in server.js) means X-Forwarded-Proto is honored when forwarded from local/private-network proxies, which covers Docker network setups and most self-hosted deployments behind NPM, Traefik, or Caddy. auto is strictly opt-in. The default behavior is unchanged, so existing users see no difference. A follow-up release can consider promoting auto to the default after real-world feedback. Also fixed (latent bug, benefits everyone) Cookie clear operations (clearAdminAuthCookie, clearGalleryAuthCookies) previously wrote the same `secure` attribute as the set path. When a cookie was set with Secure=true over HTTPS and the clear request came over HTTP (or vice versa under auto mode), some browsers would reject the Set-Cookie delete header, leaving the cookie in place. Browsers match cookies by (name, domain, path) for deletion and don't care about Secure, so the new buildClearCookieOptions() helper simply omits the secure attribute. Implementation - secureCookie string is replaced by secureCookieMode which can hold true, false, or 'auto'. - New resolveSecureFlag(res) returns the boolean for a specific response, delegating to res.req.secure when in auto mode. - buildCookieBaseOptions and buildCookieOptionsWithExpiry now take res and pass it through. - New buildClearCookieOptions() deliberately omits `secure`. - setAdminAuthCookie / setGalleryAuthCookies / clearAdminAuthCookie / clearGalleryAuthCookies all updated to thread res where needed. Public signatures unchanged — every caller already has res in scope. Testing Verified against a real Express instance inside the backend container with trust proxy configured, covering: - (unset) + NODE_ENV=production -> secure: true (legacy) - (unset) + NODE_ENV=development -> secure: false (legacy) - COOKIE_SECURE=true + req.secure=false -> secure: true (literal wins) - COOKIE_SECURE=false + req.secure=true -> secure: false (literal wins) - COOKIE_SECURE=auto + X-Forwarded-Proto: https -> secure: true - COOKIE_SECURE=auto + plain HTTP -> secure: false - clearCookie always omits the secure attribute Documentation Added a COOKIE_SECURE block to both .env.example files (root for docker-compose, backend/.env.example for native install) explaining the four values, when to use auto, and the two requirements (proxy must forward X-Forwarded-Proto, proxy IP must be in the trust list). Also documented COOKIE_SAMESITE and COOKIE_DOMAIN alongside, which were previously undocumented. |
||
|
|
77f07e9329 |
fix: guest feedback flow bugs in Masonry grid and PhotoLightbox (#292)
Fixes two bugs reported on #292 after 3.27.0-beta.0 shipped: 1. Masonry grid showed no visual feedback after liking a photo. MasonryGalleryLayout's Like button had no liked-state plumbing — the Heart icon was a static <Heart> regardless of whether the user had liked the photo. 2. PhotoLightbox (fullscreen view) silently failed to like photos in guest identity mode. submitLike() and submitRating() never called ensureIdentity() before firing the API request, so the first interaction from a fresh session hit a 401 from the server instead of opening the name prompt. Root causes: 1. MasonryGalleryLayout was missing the 'liked' state pattern that GridGalleryLayout already uses (likedPhotoIds Set in the parent, passed down as a `liked` prop, updated via onLikeSuccess callback). The bug was invisible in simple mode (no personal state) but surfaced immediately in guest mode where each guest expects to see confirmation of their own action. 2. PhotoLightbox's submit handlers were written before the guest identity context existed and only checked the legacy require_name_email flag. They were never updated when guest mode landed. Also fixed: z-index conflict where the GuestNamePromptModal (z-50) was sitting at the same level as PhotoLightbox (z-50), so when the prompt opened over the lightbox, the fullscreen image intercepted pointer events and the modal's Continue button was unclickable. Bumped both guest modals to z-[60]. Changes: - MasonryGalleryLayout.tsx - MasonryPhotoProps gains `liked?: boolean` + `onLikeSuccess?: () => void`. - Like button: red bg + filled white Heart icon when liked; aria-label toggles between "Like photo"/"Unlike photo"; aria-pressed mirrors state. - onClick wires onLikeSuccess() for optimistic UI in both guest-mode and simple-mode branches plus the FeedbackIdentityModal onSubmit path. - Parent layout holds `likedPhotoIds: Set<number>` and passes it to each MasonryPhoto (matches the GridGalleryLayout pattern). - PhotoLightbox.tsx - Consumes useGuestIdentityOptional(); new `isGuestMode` flag. - submitLike() and submitRating() get a guest-mode branch that calls ensureIdentity() first and submits without body guest_name/email (server reads from the verified token). - Optimistic UI updates happen after successful submit in guest mode. - GuestNamePromptModal.tsx, GuestRecoveryModal.tsx - z-50 → z-[60] so they render above PhotoLightbox. Verified end-to-end against local Docker with Playwright MCP on event 168 (Masonry Columns Test layout): - Fresh session, click Like in Masonry grid → name prompt opens, register, feedback persists with guest_id, Heart button turns red with aria-pressed and "Unlike photo" label. Subsequent likes on other photos also show red state. DB confirms feedback rows. - Fresh session, open photo in lightbox BEFORE registering → click Like, the name prompt correctly opens on top of the lightbox, register, feedback persists. Rate 4 stars → works, average 4.0 (1) displayed in lightbox, ★ badge appears on toggle-feedback button, grid cell shows "1 likes" + "Rating: 4.0" indicators after closing lightbox. - Backend DB: gallery_guests row created, photo_feedback rows have correct guest_id, server reads name from verified token (body values ignored). Out of scope (documented in audit, not reported by the user, no regression from guest mode): Mosaic/Carousel/Timeline have partial optimistic-UI issues unrelated to this report; they pre-date guest mode and behave the same in simple mode. Leaving alone per scope discipline. |
||
|
|
ad4e5a7506 |
feat: guest selections with per-person identity (#292)
Introduces a new "Per-guest selections" identity mode for event feedback, letting each visitor register under their own name so their likes/favorites/comments/ratings are tracked independently. Includes admin insights (list, per-guest detail, aggregate view, export) and advanced identity features (forget-me, email recovery, invite tokens, merge). New event-level setting - event_feedback_settings.identity_mode = 'simple' | 'guest' (default 'simple' → zero behavior change for existing events). - Admin UI radio under Feedback Settings to toggle per event. Root cause of the previous "all guests share state" bug - generateGuestIdentifier() was sha256(ip + userAgent), so every visitor on the same WiFi + similar device collided into one identity. - Now: when a verified guest JWT is present (x-guest-token header), req.guest.identifier takes precedence — per-person rate limits and per-person deduplication. Phase 1 — identity layer - Migration 078: new gallery_guests, guest_invites, guest_verification_ codes tables; identity_mode column + check constraint; nullable guest_id FK on photo_feedback. - New guest JWT type scoped to (eventId, guestId). - New middleware guestAuth.resolveGuest (non-blocking) + requireGuest. - POST /gallery/:slug/guest, GET /guest/me, DELETE /guest/me. - Gallery feedback route enforces guest identity in guest mode and reads name/email from the verified token (never from the body). - Frontend GuestIdentityContext + GuestNamePromptModal; axios interceptor injects x-guest-token on gallery API calls. - Feedback-only blocking: gallery opens freely, prompt only on first interactive feedback action. - Admin "Guests" tab (conditional on identity_mode='guest') with the AdminGuestsList component. Phase 2 — admin insights - GET /admin/events/:eventId/guests list + aggregated counts. - GET /admin/events/:eventId/guests/:guestId detail with per-type groupings; AdminGuestDetail modal with thumbnail grid + tabs. - GET /admin/events/:eventId/guests/aggregate sorted by distinct guest pick count; GuestSelectionsAggregate component. - Per-guest export (txt/csv/json) and bulk export-all ZIP. Phase 3 — polish - 3.1 Self-service forget-me link in gallery footer. - 3.2 Email-based identity recovery: POST /guest/recover sends a 6-digit code via the existing emailProcessor, POST /guest/verify exchanges it for a token (rate-limited, enumeration-safe). - 3.3 Admin invite tokens: pre-mint identities, share URLs with ?invite=, single-use redemption stripping the param from history. - 3.4 Admin merge endpoint reassigns feedback + soft-deletes sources. Shared helper - useGalleryFeedbackAction hook wraps the identity-check logic for inline like buttons across Masonry/Grid/Justified/Mosaic/Carousel/ Timeline/Premium layouts. Backwards compatibility - Existing events default to 'simple' after migration; behavior unchanged. - Legacy photo_feedback rows keep guest_id NULL; admin shows them in the generic feedback moderation view as before. - feedback_count denormalized stat now uses COALESCE(guest_id, guest_identifier) so per-guest counts are accurate without touching legacy rows. Verified end-to-end against local Docker - Migration clean on existing data. - Simple mode unchanged (no prompt, legacy flow). - Guest mode: Alice registers on click, tokens persist in sessionStorage, feedback rows carry guest_id. - Carol via invite link auto-redeems, sees Alice's "1 likes" badge. - Admin Guests tab shows both with correct counts; detail modal displays thumbnail grid with badges; aggregate view sorts by picker count (photo 227 = 2, others = 1); CSV/JSON export matches DB. - Merge Carol into Alice: feedback reassigned, Carol soft-deleted, Alice count = 4. |
||
|
|
d4b4dc628f |
fix: wire admin photo feedback filters into grid query (#293)
The Has Likes / Has Favorites / Has Comments checkboxes in the admin Event > Photos tab updated local state but never affected the visible photo grid, because the feedbackFilters state was only wired to the export menu and the backend /admin/photos/:eventId/photos endpoint had no support for these params. Fixes: - backend/src/routes/adminPhotos.js: extend GET /:eventId/photos to accept has_likes, has_favorites, has_comments, min_rating, and logic (AND/OR) query params and apply them via where-clause groups using the existing denormalized like_count/favorite_count/comment_count/ average_rating columns. - frontend/src/services/photos.service.ts: add hasLikes, hasFavorites, hasComments, minRating, logic to the PhotoFilters interface and append them as query params in getEventPhotos. - frontend/src/pages/admin/EventDetailsPage.tsx: merge feedbackFilters into combinedPhotoFilters (via useMemo) and key the admin-event-photos query on it, so toggling any checkbox refetches with the new params. Verified end-to-end against local Docker: seeded event with a known feedback distribution and confirmed - Has Likes → 4 photos - Has Favorites → 3 photos - Likes AND Favorites → 1 photo - Likes OR Favorites → 6 photos - Has Comments → 2 photos - network requests carry the exact query params |
||
|
|
ee0baafc59 |
docs: clarify file system photo import requires existing event (#269)
The "Method 2: File System" section in SIMPLE_SETUP.md implied you could create a gallery by just copying files to the storage directory. In reality, the event must exist in the database first — the file watcher only adds photos to existing events. Rewritten to clarify the prerequisite and explain how the file watcher works (2s stability delay, supported formats, auto-thumbnailing). |
||
|
|
147dc28440 |
fix: apply password change redirect fix to regular modal too (#263)
The redirect loop fix only covered MandatoryPasswordChangeModal. The regular PasswordChangeModal (profile settings) had the same issue — onSuccess updated React state but didn't handle the new JWT cookie, causing the same redirect loop. Also increase redirect delay from 500ms to 2000ms in both modals so the success toast is visible before the page reloads. |
||
|
|
b1d16670d5 |
fix: set JWT iat after password_changed_at to prevent token rejection (#263)
The new token issued after password change had iat (integer seconds) that was <= password_changed_at (millisecond precision), causing the auth middleware's "iat < passwordChangedTime" check to reject it immediately. Set iat explicitly to 1 second after password_changed_at. E2E tested: login → mandatory password change → dashboard loads successfully with no redirect loop and no 401 errors. |
||
|
|
633d4a0f30 |
feat: sort photos by capture date with configurable default sort (#283)
Add per-event default photo sort setting with 6 options: - Upload Date (Newest/Oldest First) - Date Taken (Newest/Oldest First) — uses EXIF captured_at - Filename (A-Z / Z-A) Backend: - Migration 077 adds default_photo_sort column to events table - Event create/update handlers accept and validate the setting - Gallery info endpoint returns default_photo_sort for frontend Frontend: - "Date Taken" added to gallery sort dropdown (alongside Date, Name, Size, Rating) - Gallery initializes with event's default sort instead of hardcoded "date" - "Default Photo Sort" dropdown in event create and edit forms - Photos without EXIF dates fall back to upload date i18n: All 5 locales (EN, DE, NL, PT, RU) updated with sort labels. Closes #283 |
||
|
|
835bdf5abb |
fix: resolve password change redirect loop and file watcher crash
#263: The mandatory password change modal updated React state before the browser stored the new JWT cookie, causing a race condition where the auth context checked the session with the old (invalidated) token. Replace the state update with a full page redirect to /admin/dashboard after a brief delay, ensuring the new cookie is applied cleanly. #269: The file watcher service imported isVideoMimeType from fileSecurityUtils where it doesn't exist. The function is exported from videoProcessor. Fix the import path. Closes #269 |
||
|
|
40332a71db |
feat: draft mode, admin branding, and workflow improvements
Draft Mode: - Events are created as drafts by default — no email sent until published - Add "Publish & Notify Client" button with confirmation dialog - Draft banner with yellow styling on event details page - Draft filter tab in events list - Gallery middleware blocks public access to draft events - Migration 076 adds is_draft column to events table Admin Draft Preview: - Admins can preview draft galleries via JWT preview token (?preview=) - "View Gallery" link on drafts auto-appends preview token Admin & Login Page Branding: - Admin header uses configured company logo/name from branding settings - Login page shows configured logo instead of hardcoded PicPeak - Respects logo_display_mode (logo_only, text_only, logo_and_text) OG Tag Branding: - DynamicFavicon component updates OG meta tags and page title from branding settings Editable Client Email: - Customer email is now editable after event creation in edit mode Branding Inheritance: - New events inherit hero logo settings (visibility, size, position) from global branding configuration Share Link Full Domain URL: - New getFrontendBaseUrl() utility with DB fallback to general_site_url - Used in email processor and share link service |
||
|
|
83868ffe2f |
security: fix 20 dependency vulnerabilities (11 error, 7 warning, 2 note)
Update direct dependencies and overrides to address GitHub code scanning alerts: - handlebars 4.7.8 -> 4.7.9 (5 CVEs: RCE, DoS, XSS, code execution) - nodemailer 7.0.12 -> 7.0.13 (SMTP command injection) - tar 7.5.11 -> 7.5.13 override (symlink/hardlink path traversal) - fast-xml-parser >=5.3.8 -> >=5.5.10 override (entity expansion bypass) - brace-expansion >=5.0.0 -> >=5.0.5 override (DoS via zero step) - path-to-regexp 0.1.12 -> 0.1.13 override (ReDoS via malformed URL params) - lodash 4.17.23 -> >=4.18.1 override (prototype pollution, code execution) The picomatch CVEs are in npm's own node_modules inside the Docker image and do not affect application code. |
||
|
|
bec36fc99f |
security: pin axios to 1.14.0 to prevent supply chain attack
Axios versions 1.14.1 and 0.30.4 were compromised on March 31, 2026 with a RAT dropper (plain-crypto-js) attributed to North Korean threat actor UNC1069/Sapphire Sleet. The malicious versions have been removed from npm but our ^1.12.2 range could have pulled 1.14.1 on next install. Pin to exact version 1.14.0 (latest safe release) in both frontend and backend package.json and lock files to prevent any future resolution to compromised versions. References: - https://github.com/axios/axios/issues/10604 - https://snyk.io/blog/axios-npm-package-compromised-supply-chain-attack-delivers-cross-platform/ |
||
|
|
3c8d344ddd |
fix: resolve redirect loop after mandatory password change (#263)
After changing password, the backend sets password_changed_at which invalidates the old JWT token. But the frontend still holds the old token in the HttpOnly cookie, so the next session check returns 401, triggering an infinite redirect loop between /admin/login and /admin/dashboard. Fix: issue a new JWT token cookie after successful password change so the session remains valid without requiring re-login. |
||
|
|
ee3f6ae13b |
feat: warn about low thumbnail resolution when selecting beta themes
Beta themes (Gallery Premium, Gallery Story) display thumbnails at 400-800px, but the default thumbnail size is 300x300px, causing visible pixelation. Show an amber warning banner with a link to Thumbnail Settings when a beta layout is active and thumbnails are below 500px. Warning appears both in the preset selector and the layout selector sections of the theme customizer. |
||
|
|
978e4473b5 |
fix: pin npm upgrade to v10 in backend Dockerfile
npm@latest resolves to v11 which has a broken promise-retry dependency on Node 22 Alpine, causing Docker builds to fail. Pin to npm@10 which stays compatible with the Node 22 base image. |
||
|
|
f50d7c0c51 |
feat: multilingual email templates with translations table
Replace column-based email template languages (subject_en/subject_de) with a normalized email_template_translations table where each language is a row. This allows adding new languages without schema changes. - Add migration 075 to create email_template_translations table, migrate existing EN/DE data, and seed NL/PT/RU for customer-facing templates - Update processTemplate() to query translations table with fallback chain (requested lang -> en -> first available), with legacy column fallback - Restructure admin email API to return/accept translations object format - Update frontend EmailConfigPage with dynamic 5-language tabs, translation count badges, and copy-from-language feature for empty translations - Add Dutch to default language dropdown in general settings - Add Dutch to clientAccessI18n and password security messages in emails - Expand email domain detection for NL/BE/BR/PT/RU domains - Add email i18n keys (copyFrom, noTranslation, etc.) across all 5 locales |
||
|
|
b54a80d251 |
feat: add Dutch (nl) locale and fix missing translation keys across all locales
Add complete Dutch translation (2054 keys) with Netherlands flag in the language selector. Also synchronize all existing locales so every language has the same set of keys: added 29 missing keys to EN/RU/PT and 95 missing keys to DE (moderation, analytics, CSS templates, backup, events). |
||
|
|
e1b6e43e52 |
feat: photo visibility control with client access (#172)
Add two-tier gallery access system allowing clients (e.g., wedding couples) to review and hide photos before the gallery is shared with guests. Backend: - Migration 074: add visibility column to photos, client_access_enabled/ client_password_hash/client_share_token to events - Client login endpoint (POST /auth/gallery/:slug/client-login) with bcrypt PIN - Gallery photo list filters hidden photos for guests, shows all for clients - Visibility toggle endpoints (single + bulk) for client access level - Admin event CRUD supports client access fields - Email template includes client access link + PIN (EN/DE/RU/PT) Frontend: - ClientAccessPage: PIN entry form at /gallery/:slug/client-access - GalleryView: client mode banner, visibility counter, toggle controls - GridGalleryLayout: eye/eye-off overlay per photo for clients - AdminPhotoGrid: visibility badge, bulk Hide/Show buttons - EventDetailsPage: Client Access settings section (toggle, PIN, link) - CreateEventPage: client access toggle + PIN in event creation form - GalleryAuthContext: accessLevel/isClient/clientLogin support - New complete pt-BR locale (pt.json) with all translations - Client access i18n keys for EN, DE, RU, PT |
||
|
|
f3622396e7 |
fix(security): invalidate tokens on password change, enforce session timeout, fix role update
- Set password_changed_at when changing password via adminAuth route so existing JWT tokens are rejected by the auth middleware check - Enforce session timeout on first request with unseen tokens by checking token iat against configured timeout (prevents bypass after server restart) - Convert camelCase roleId/isActive to snake_case role_id/is_active in frontend updateUser service (fixes silent role update failures) Resolves GHSA-rqg3-47p5-vgwg |
||
|
|
bbeedd1888 |
fix: resolve external media dimensions, gallery theme race condition, and add email color customization
- Fix dimension repair for external media by using photoResolver instead of hardcoded paths - Extract photo dimensions via Sharp during external media import - Remove duplicate theme useEffect from GalleryView to prevent flash/revert race condition - Pass event welcome_message to Story layout footer for per-event customization - Add email_primary_color/email_secondary_color settings with admin UI color pickers - Add i18n keys for email branding in all 4 locales (en, de, ru, pt) |
||
|
|
1fa222e9c4 |
feat: add photo cap per event and Portuguese (pt-BR) locale
- Add photo_cap column to events table (migration 074) to limit photos per event - Enforce photo cap in upload route, returning 400 when limit exceeded - Pass photo_cap through all event CRUD routes and frontend forms - Add complete Portuguese (pt-BR) translation (2300+ strings) - Register pt locale in i18n config, language selector, date formatting - Add photoCap/photoCapHelp translation keys to all locale files (en, de, ru, pt) |
||
|
|
1f524f2358 |
fix: update dependencies to resolve code scanning security alerts
- Upgrade multer to 2.1.1 (CVE-2026-3520, DoS via malformed requests) - Update tar override to >=7.5.11 (CVE-2026-31802, CVE-2026-29786) - Upgrade Node base image from 20-alpine to 22-alpine to fix npm bundled tar/minimatch CVEs in the Docker image |
||
|
|
fc0911acf8 |
fix: wrap email preview with full styled header/footer template
The email template preview modal was showing only raw body HTML without the styled wrapper (green header bar, logo, footer with company name) that processTemplate() applies when sending. This made preview not match what recipients actually receive. Extract wrapEmailHtml() from processTemplate() and reuse it in the preview endpoint. Also fix logo URL to use FRONTEND_URL consistently. Closes #229 |
||
|
|
6f95b8c26c |
feat: register Russian locale and add to language selector
Import ru.json translations in i18n config and add Russian with flag to the language selector dropdown. |
||
|
|
7250c427b9 |
fix: shorten Save button label on email template editor
Change "Save Changes" to "Save" for cleaner toolbar layout. |
||
|
|
04a7ea80f9 |
feat: add visual WYSIWYG email template editor (#229)
Replace raw HTML textarea with TipTap-based rich text editor for email templates. Includes formatting toolbar, variable insertion dropdown, source/visual toggle, and dark mode support. Add Mailhog service to docker-compose for local email testing. |
||
|
|
908ab08815 | Merge beta to resolve conflicts for PR #232 | ||
|
|
52ab609597 |
i18n: add missing Russian translations for thumbnails and photo dimensions
Adds 38 missing keys for settings.thumbnails and settings.photoDimensions that were added after the initial Russian localization PR (#216). |