Compare commits

..

154 Commits

Author SHA1 Message Date
Paul Nothaft b86669f1e1 Merge pull request #569 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.44.0
2026-05-27 21:51:33 +02:00
github-actions[bot] 80296282e8 chore(main): release 3.44.0 2026-05-27 19:50:15 +00:00
Paul Nothaft 5551c89bda Merge pull request #568 from the-luap/release/3.55.0-merge-from-beta
chore(release): promote beta → main as v3.55.0
2026-05-27 21:48:31 +02:00
Paul Nothaft dbde67c0fa Merge branch 'main' into release/3.55.0-merge-from-beta
Resolves 6 file conflicts arising from main carrying 7 weeks of
stable-channel work (security backports, release-please cuts, README
rewrite #281) that hadn't been forward-merged into beta.

Resolution per file:

- backend/package.json + package-lock.json — kept beta's version.
  Beta is the superset; it intentionally drops `handlebars` (PR #367
  removed the runtime require; the dep was the source of 2 criticals
  + 8 highs). Security-pinned versions (axios 1.15.2, nodemailer ^8,
  i18next-http-backend ^3.0.2, multer ^2.0.2, tar >=7.5.13) already
  match across both branches — no security regression.
- frontend/package.json + package-lock.json — kept beta's version.
  Superset of main (adds marked, @types/node, i18next-cli, memfs,
  i18n CLI scripts). Same security versions on both sides.
- README.md — kept main's version. PR #281 was an explicit cleanup
  ("shorter, cleaner, less AI-sounding"); beta had grown the file by
  326 lines ad-hoc during the freeze. Preserving the rewrite.
- CHANGELOG.md — kept main's version. Release-please regenerates from
  conventional commits on its next stable cut, so beta's accumulated
  entries will roll into the new v3.55.0 release block automatically.

Auto-merged files carrying main's session-invalidation fix (#245)
flowed cleanly into beta's versions — sessionTimeout.js, adminAuth.js,
and the test files all merged without conflict, meaning beta had
already absorbed equivalent changes by independent paths.

CI on the underlying merge state was green on PR #568 prior to this
resolution; will re-run automatically on push.
2026-05-27 21:45:32 +02:00
Paul Nothaft 3ceeccd85a Merge pull request #562 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.55.0-beta.0
2026-05-27 16:01:01 +02:00
github-actions[bot] fd8ee5d52c chore(beta): release 3.55.0-beta.0 2026-05-27 13:53:11 +00:00
Paul Nothaft e016f510b6 Merge pull request #561 from the-luap/fix/android-download-latency-554
fix(lightbox+events): Android download lag, multi-photo Web Share re-land, theme branding inheritance
2026-05-27 15:52:44 +02:00
Paul Nothaft d5a37df2c4 fix(events): preserve branding inheritance when saving events with null color_theme
API-created events (and any event whose `color_theme` is NULL) had two
visible bugs in the admin edit page (#550 follow-up — PR #552 fixed the
v1 POST write path, this fixes the read/save path):

1. The theme picker initialised to the hardcoded `GALLERY_THEME_PRESETS
   .default.config` ("Classic Grid", green) — which had nothing to do
   with the admin's actual branding palette, while the gallery itself
   was rendering with the branding theme. Confusing visual mismatch.
2. Saving the event for ANY reason (changing the date, password, etc.)
   wrote `color_theme = 'default'` back to the row because the save
   handler always emitted the picker's initial preset name. That
   silently replaced "inherit from branding" with the literal Classic
   Grid preset, so the gallery's visuals jumped.

Two fixes, both in EventDetailsPage:

- Add a `themeChanged` flag, defaulted false. Flip in the picker's
  onChange / onPresetChange / onSyncFromBranding callbacks. The save
  handler now only writes `updateData.color_theme` when the flag is
  true, so saving without touching the picker preserves NULL.
- When `event.color_theme` is null and `publicSettings.theme_config`
  (the site branding) is available, initialise `currentTheme` from
  branding instead of the Classic Grid preset, with currentPresetName
  set to 'custom' (since inherited branding isn't a named preset).
  Falls back to the Classic Grid preset only when no branding theme
  exists either.

Combined effect: opening an API-created event shows the same palette
the gallery uses, and saving without changing the theme preserves the
inheritance. Existing events with a stored color_theme are unaffected
(themeChanged stays false → no write, just like before for the
common no-change-to-theme save).
2026-05-27 15:44:41 +02:00
Paul Nothaft d5823c79d9 feat(lightbox): multi-photo Web Share save-to-Photos on iOS (#557)
Extends #531 to the selection-based bulk-download flow. On iOS with a
selection at or under MAX_WEB_SHARE_FILES (25), galleryService
.downloadSelectedPhotos now routes through navigator.share({ files })
so the photos land directly in Photos via the share sheet's "Save N
Images" action. Above the cap, anywhere off-iOS, or on any failure,
the existing server-side zip path runs unchanged.

The 25-file cap is the empirically-safe ceiling: iOS Safari's share
sheet starts choking beyond ~25–30 files, and every File materialises
as an in-memory Blob before share() is invoked, so a 500-photo
selection would buffer multiple GB on the device.

trySaveMultipleToDevice exposes three outcomes:
- 'shared'    — share() resolved; flow ends
- 'dismissed' — user cancelled (AbortError); flow ends without zip
                fallback so dismissal isn't silently overridden
- 'fallback'  — capability missing or unexpected failure; caller
                takes the zip path

Partial shares are deliberately avoided: a single failed photo fetch
collapses the whole selection back to the zip endpoint rather than
sharing only the photos that resolved.

All 4 grid callers (PhotoGrid, PhotoGridWithLayouts, GalleryStoryLayout,
GalleryPremiumLayout) funnel through downloadSelectedPhotos, so no
caller-side changes are needed. Android, desktop, Firefox, and
"Download All" are untouched.

Layers on top of #556 (iOS-only gating via isIOS()). Builds against the
fix/android-download-web-share-554 branch.
2026-05-27 15:37:52 +02:00
Paul Nothaft 04795219a0 fix(lightbox): eliminate download lag on Android by skipping the blob round-trip
`savePhotoToDevice` previously buffered the full image through JS as a
Blob on every platform before clicking <a download>. On cellular this
added ~5s of dead air between the button press and the browser's
download dialog, prompting users to re-click and produce duplicate
downloads (#554 follow-up, post-#556).

The blob round-trip is only required for the iOS Web Share path
(`navigator.share({files})` needs File objects in hand). On Android and
desktop the browser can fetch the download URL itself and show its own
progress in the notification shade — instantly. So iOS keeps the
existing flow; everywhere else gets a direct anchor navigation.

The new `triggerDirectDownload` helper uses `api.getUri()` so the path
also works in split-origin deployments (where the existing hardcoded
`/api/...` pattern used by `downloadAllPhotos` would 404).

Tests updated: Android / desktop / regular-Mac branches now assert that
`fetchPhotoBlob` is NOT called and `triggerDirectDownload` is invoked
with a `/gallery/{slug}/download/{id}` URL. iOS tests unchanged.
2026-05-27 13:16:36 +02:00
Paul Nothaft 9ae1f79769 Merge pull request #559 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.7-beta.0
2026-05-26 22:54:48 +02:00
github-actions[bot] 54b185db47 chore(beta): release 3.54.7-beta.0 2026-05-26 20:54:29 +00:00
Paul Nothaft 578397bc6b Merge pull request #556 from the-luap/fix/android-download-web-share-554
fix(lightbox): restrict Web Share save-to-Photos path to iOS (#554)
2026-05-26 22:54:06 +02:00
Paul Nothaft 2a309c75a7 fix(lightbox): restrict Web Share save-to-Photos path to iOS (#554)
PR #531 routed the single-photo download through navigator.share()
whenever canShare({files}) returned true, on the assumption that any
mobile share sheet would expose a "Save Image" action. That holds on
iOS — Safari's share sheet has a first-party "Save to Photos" entry —
but on Android the system share sheet only lists installed apps that
registered an image/* intent (WhatsApp, Telegram, Drive, etc.). There
is no built-in save-to-Gallery action, so Android users tapping the
download button got an app-picker instead of the file saved to their
device.

Fix: gate the Web Share branch behind a UA-based isIOS() check. Android,
desktop, and everything else fall through to the existing <a download>
path (file lands in Downloads, visible in the Photos / Gallery app
afterwards — same behaviour as before #531). iOS — including iPadOS
13+, which reports as MacIntel + touch — keeps the share-sheet flow
that drops directly into Photos.

UA-sniff is the only available signal here: canShare({files}) is true
on both iOS Safari and Chrome Android, so feature detection cannot
distinguish them.

Tests pin all six scenarios — iOS share path, Android download fallback
(even with canShare=true), desktop, iPadOS-as-Mac detected as iOS, regular
Mac NOT detected as iOS, AbortError dismissal preserved (no surprise
fallback), and non-Abort share() rejection falls back to download.
2026-05-26 22:37:01 +02:00
Paul Nothaft b5e7f9cec1 Merge pull request #553 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.6-beta.0
2026-05-26 11:29:23 +02:00
github-actions[bot] 08fa2e9b63 chore(beta): release 3.54.6-beta.0 2026-05-25 20:19:09 +00:00
Paul Nothaft 7ef0e40e7c Merge pull request #552 from the-luap/fix/v1-events-feedback-theme-550
fix(api/v1): accept color_theme + create feedback row on event create (#550)
2026-05-25 22:18:49 +02:00
Paul Nothaft 1b521e761c fix(api/v1): accept color_theme + create feedback row on event create (#550)
POST /v1/events was a strict subset of the admin create path: it did not
accept color_theme on the body, and it skipped the event_feedback_settings
insert that adminEvents.js does. Two visible bugs followed.

1. Editing an API-created event in the admin UI snapped the theme picker
   to GALLERY_THEME_PRESETS.default (EventDetailsPage.tsx falls through to
   the default preset when event.color_theme is falsy), and saving wrote
   that default back. Inherited themes were silently clobbered.

2. The "Enable Guest Feedback by default" admin setting (#520) did not
   apply to API-created events. With no event_feedback_settings row the
   gallery UI reads feedback as off, regardless of
   event_default_feedback_enabled.

Fix mirrors the admin path:

  - color_theme accepted on the request body (optional, persisted as-is —
    preset name or JSON-encoded ThemeConfig, same shape adminEvents
    stores).
  - feedback_enabled accepted on the request body; when omitted, falls
    back to the event_default_feedback_enabled global setting (same
    behaviour adminEvents.js:511-520 implements via readBooleanSetting).
  - event_feedback_settings row inserted when feedback resolves to true,
    using the same sub-flag defaults as the admin form (everything on
    except require_name_email).

OpenAPI JSDoc updated so docs.picpeak.app picks up the new fields.

Tests cover all four scenarios — explicit color_theme persisted, JSON
theme persisted verbatim, explicit feedback_enabled creates the row,
omitted feedback_enabled honours the global setting, and a validator
regression for non-boolean feedback_enabled.
2026-05-25 10:29:33 +02:00
Paul Nothaft ce1ccf0a4d Merge pull request #549 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.5-beta.0
2026-05-22 14:43:15 +02:00
github-actions[bot] 793aa1247e chore(beta): release 3.54.5-beta.0 2026-05-22 12:41:21 +00:00
Paul Nothaft b351d17ee9 Merge pull request #548 from the-luap/fix/nginx-forwarded-proto-547
fix(nginx): honour outer X-Forwarded-Proto when behind a reverse proxy (#547)
2026-05-22 14:40:55 +02:00
Paul Nothaft 5488de3383 fix(nginx): honour outer X-Forwarded-Proto when behind a reverse proxy (#547)
When PicPeak runs behind NPM / Traefik / Caddy, the inner nginx receives
plain HTTP from the outer proxy. The previous `X-Forwarded-Proto $scheme`
therefore always forwarded "http" to the backend, even when the public URL
was HTTPS. Express has `trust proxy` enabled for loopback/linklocal, so
req.secure became false, the Secure cookie flag wasn't set, and generated
URLs (cookies, tokens) used http://.

Add a top-of-file `map` block that picks the incoming X-Forwarded-Proto
when present and falls back to `$scheme` for direct access. Applied to both
nginx.conf (bundled production image) and nginx.dev.conf.

Validated with `nginx -t` against nginx:1.28-alpine (the same image used
by Dockerfile.prod / Dockerfile).
2026-05-22 13:32:01 +02:00
Paul Nothaft 6b6ac64346 Merge pull request #537 from rpintodasilva/imp/french-translation
French Transalation - v2
2026-05-22 10:30:44 +02:00
Paul Nothaft 27e9b0535e Merge pull request #545 from the-luap/chore/clawpatch-review-fixes
chore: address clawpatch review findings
2026-05-21 19:07:17 +02:00
Paul Nothaft dba98f1325 chore: address clawpatch review findings (test scope, deps, legal-page hardening)
- frontend: `npm test` now runs all 7 vitest suites instead of one hardcoded
  file; the previously skipped ProtectedImage / Skeleton / usePublicSettings /
  contrast / themeMigration / url suites are now active in CI
- frontend: wrap ThemeCustomizerEnhanced test in QueryClientProvider so the
  newly-enabled run passes (component uses useQuery internally)
- root: drop unused better-sqlite3 / canvas / node-fetch + their
  prebuild-install/tar-fs override (backend keeps its own copies); add dotenv
  so playwright.config.ts can load on a clean install; add name/version/private
- LegalPage: scheme-validate external_url before window.location.replace so a
  CMS edit can't redirect visitors to javascript:/data:
- LegalPage: force rel="noopener noreferrer" on target="_blank" anchors in
  sanitized CMS HTML to block reverse-tabnabbing
2026-05-21 17:10:34 +02:00
Paul Nothaft 1cf492f4b9 Merge pull request #544 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.4-beta.0
2026-05-21 10:24:50 +02:00
github-actions[bot] 955ada4945 chore(beta): release 3.54.4-beta.0 2026-05-21 08:24:22 +00:00
Paul Nothaft 9607b4666c Merge pull request #542 from the-luap/fix/recover-orphaned-527
fix: recover three orphaned commits from #527 (BRAND_TITLE runtime, Web Share, pan zoom)
2026-05-21 10:23:59 +02:00
Paul Nothaft 8c36f80471 Merge pull request #543 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.3-beta.0
2026-05-21 10:22:59 +02:00
github-actions[bot] c1e8e0d73a chore(beta): release 3.54.3-beta.0 2026-05-21 08:22:26 +00:00
Paul Nothaft 3e39112a12 Merge pull request #541 from the-luap/fix/lightbox-heart-icon-fill-538
fix(lightbox): fill the heart icon when liked (#538 follow-up)
2026-05-21 10:21:59 +02:00
Paul Nothaft efa6b4a205 fix(brand-title): runtime substitution so GHCR-image users can override (#521 follow-up)
@Rekoo-PS confirmed the prior #521 fix landed on beta but reported the
preview still shows the default "PicPeak" title — their brand is
"arkan-studio". Root cause: that fix used Vite's build-time
%VITE_DEFAULT_TITLE% substitution. Self-hosters running the pre-built
ghcr.io/the-luap/picpeak/frontend image can't override at build time
without rebuilding, so they were stuck with whatever the upstream
build baked in.

Pivot to runtime substitution: the frontend container now reads
BRAND_TITLE / BRAND_DESCRIPTION env vars on startup and envsubsts
them into index.html. Change the values in .env, restart the frontend
service, done — no rebuild required.

Mechanics:
  - frontend/index.html: tokens are now ${BRAND_TITLE} /
    ${BRAND_DESCRIPTION} (shell expansion syntax, passes through Vite
    unchanged into the built dist).
  - frontend/Dockerfile: install gettext (provides envsubst), snapshot
    /usr/share/nginx/html/index.html → index.html.tpl at build, install
    docker-entrypoint.sh, wire ENTRYPOINT to it. The .tpl is the
    immutable source — every container start re-renders index.html
    from .tpl, so restarts pick up new env values cleanly (no
    accidental "first-boot env stuck forever" trap).
  - frontend/docker-entrypoint.sh: applies defaults if env unset,
    runs envsubst (locked to BRAND_TITLE + BRAND_DESCRIPTION
    explicitly so /assets/*.js template literals aren't touched if
    anyone ever extends substitution to the bundle), execs nginx.
  - frontend/vite.config.ts: drop the htmlTitleDefaults plugin — no
    longer needed since substitution is fully runtime.
  - frontend/.env.example + .env.production.example: drop the
    VITE_DEFAULT_* docs (the vars no longer have effect).
  - docker-compose.yml + docker-compose.production.yml: pass
    BRAND_TITLE / BRAND_DESCRIPTION env into the frontend service
    with sensible defaults so unconfigured installs work unchanged.
  - .env.example: add BRAND_TITLE / BRAND_DESCRIPTION with comment
    pointing at the social-preview use case.

Verified end-to-end against the built image:
  - BRAND_TITLE="Arkan Studio" BRAND_DESCRIPTION="Wedding photographs
    by Arkan Studio" → index.html serves <title>Arkan Studio</title>
    + og:title="Arkan Studio" + og:description correctly substituted.
  - .tpl preserves ${...} tokens so the next restart can re-substitute.
  - Bundle assets unaffected.
  - Defaults applied when env unset → <title>PicPeak</title>.

Docs PR in picpeak-docs describes the two new env vars under
"Social link preview fallback" in the environment-variables reference.

Refs: #521
2026-05-21 10:00:21 +02:00
Paul Nothaft 53139b8cb8 fix(lightbox): pan zoomed image with single-finger touch on mobile (#532)
@Rekoo-PS reported zoom on mobile only shows the centre crop — the
image zooms but you can't pan around to see other parts. Single-finger
touch was being routed through the carousel-swipe branch which is
gated on zoom <= 1 (so swipe doesn't fight with pan), so when zoomed
the touch hit no handler at all.

Desktop has the equivalent path via handleMouseDown / handleMouseMove
(line 364), which is why this only manifests on mobile.

Add a single-finger pan branch to the touch handlers that mirrors the
mouse path:
  - handleTouchStart: when zoom > 1 and one finger, record dragStart
    relative to the existing dragOffset (so subsequent moves continue
    from where the last pan left off, not from origin).
  - handleTouchMove: when isDragging + zoom > 1 + one finger, update
    dragOffset from touch position.
  - handleTouchEnd: clear the isDragging flag (offset persists so the
    image stays where the user left it).

Also fix a latent bug surfaced while reading the pinch-zoom path:
when pinch-out drops zoom back to 1.0, dragOffset wasn't reset, so the
photo sat off-centre at natural zoom. Re-centre in handleTouchMove
when newZoom drops to <=1 with a non-zero offset.

Carousel swipe stays disabled when zoomed (existing behaviour). Mouse
path untouched. Pinch-to-zoom path untouched.

Refs: #532
2026-05-21 10:00:21 +02:00
Paul Nothaft b2bbf7efb5 feat(lightbox): save photo to Photos app on mobile via Web Share (#531)
@Jasper2213 reported non-technical clients struggle to get downloaded
photos into their Photos / Gallery app — current flow goes through the
Files folder, requires unzipping for the bulk download, and is hard to
explain over email. Browsers can't write directly to the OS Photos app
(it's a protected location), but navigator.share({ files: [...] }) opens
the native share sheet which on iOS includes "Save Image" and on Android
includes "Save to Photos" / "Save image" — exactly the affordance non-
technical users are looking for.

Plumbed through three layers:

1. galleryService — new savePhotoToDevice(slug, photoId, filename).
   Fetches the photo blob, probes navigator.canShare({ files: [file] })
   with a representative File (some browsers return true for empty
   files arrays even when they won't accept a non-empty one), and:
     - shares if supported,
     - falls back to the existing <a download> path otherwise.
   AbortError on share() means the user dismissed the sheet — that's
   a choice, not a failure, so no fallback. Any other error falls
   through to a regular download so the user still gets the file.
   Refactored the existing downloadPhoto to share the fetch + trigger
   helpers (no behaviour change for the other 3 callers; they keep
   the regular download path).

2. useGallery — new useSavePhotoToDevice() hook next to the existing
   useDownloadPhoto(). Onsuccess toast omitted because the share-sheet
   path doesn't finish from this code's perspective — the OS UI takes
   over and the user picks the destination, so "Photo downloaded" is
   misleading. Fallback path stays silent to keep the two flows
   symmetrical (the file appearing in Downloads is its own signal).

3. PhotoLightbox — swap the existing useDownloadPhoto call site to
   useSavePhotoToDevice. No UI change. Desktop unchanged. Other
   download buttons (PhotoGrid, PhotoGridWithLayouts, GalleryView
   bulk) still use useDownloadPhoto — scoping this PR to the
   lightbox download button per the discussion thread.

Browser support:
  - iOS Safari 15+:    Web Share Files → "Save Image" → Photos      ✓
  - Chrome Android:    Web Share Files → "Save to Photos" / "Save"  ✓
  - Desktop Chrome:    canShare returns false → regular download     ✓
  - Desktop Safari:    canShare returns false → regular download     ✓
  - Firefox (any):     no Web Share File support → regular download  ✓

No new tests — the flow is browser-API-driven; jsdom doesn't model
navigator.share or canShare, so a meaningful unit test would mostly
exercise the mock rather than the contract. Verified the build is
clean (tsc --noEmit + vite build both pass).

Refs: #531
2026-05-21 10:00:21 +02:00
Paul Nothaft 600c29db8a fix(lightbox): fill the heart icon when liked (#538 follow-up)
@Tietge86 spotted that both branches of the heart-icon className were
`text-white` — the conditional was a no-op, the `fill-current` class
that would actually fill the icon was missing entirely. The button
background was turning red on like, but the heart icon stayed as a
white outline against the red, making it nearly invisible.

Move text-white outside the conditional (always white against the
red/dark backgrounds the button uses), and add fill-current to the
liked branch so the heart fills in.

Same shape as bug 2 of the original report — the like state needed to
be visually unambiguous. PhotoLikes.tsx was already fixed in this PR;
this catches the equivalent latent bug in the inline lightbox toolbar
button.

Also: bug 4 of the original report (recovery flow) turned out to be
SMTP misconfig on the reporter's end (mailhog silently dropping
emails), not a PicPeak bug. Confirmed in this thread; no further
backend changes needed.

Refs: #538
2026-05-20 17:28:50 +02:00
Paul Nothaft 4d92fb4590 Merge pull request #540 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.2-beta.0
2026-05-20 16:49:45 +02:00
github-actions[bot] 03cf09a7bf chore(beta): release 3.54.2-beta.0 2026-05-20 14:49:19 +00:00
Paul Nothaft c900be92dd Merge pull request #539 from the-luap/fix/guest-feedback-multi-538
fix(feedback): three guest-mode bugs from #538 (filter, like state, count leak)
2026-05-20 16:48:36 +02:00
Paul Nothaft 5311588baf fix(feedback): three guest-mode bugs reported in #538
Picks up three of the four bugs from @Tietge86's report. Bug 4
(recovery flow) needs network-tab data from the reporter before a fix
makes sense; commented on the issue asking for the HTTP status from
POST /gallery/:slug/guest/recover.

Bug 1 — "Liked" filter empty in guest identity mode (GalleryView.tsx)

  The feedback filter was scoping by `photo.like_count > 0`, which is
  the global aggregate across all guests. In guest identity mode the
  filter intent is "show MY picks", so a guest who'd liked photos that
  nobody else had touched got an empty grid.

  Fix: pull the current guest's interactions from /my-feedback (already
  keyed by x-guest-token in the api interceptor) into per-type
  photo-id Sets and filter against those when identity_mode === 'guest'.
  Falls back to the aggregate-count check in simple mode where there's
  no per-person identity to scope by. Same per-guest scoping applied to
  the chip-count labels ("Liked (N)" etc.) so the chip number matches
  what the filter actually surfaces — otherwise the chip says one
  count globally and the filter shows a different (smaller) one, which
  is the same UX cliff #538 originally surfaced.

  The /my-feedback query is gated on isGuestIdentityMode (not on
  filterType being feedback-related) so the chip counts are populated
  on first render. One extra request per gallery load in guest mode;
  payload is tiny.

Bug 2 — Liked state on PhotoLikes button invisible

  bg-red-50 text-red-600 is barely visible against most themes,
  especially dark + brand-coloured backgrounds. Switch to the same
  filled state the lightbox toolbar already uses
  (bg-red-500/80 text-white) so the like registers visually.
  Heart icon's fill-current was already there for the liked state —
  unchanged.

Bug 3 — Aggregate like count leaks in lightbox toolbar

  PhotoLightbox.tsx rendered {likeCount} unconditionally next to the
  inline heart button. When the admin has show_feedback_to_guests off,
  guests still saw how many other guests had liked a photo (the count
  is an admin-only metric in that mode). Gate the span on
  feedbackSettings?.show_feedback_to_guests, matching how the rest of
  the lightbox toolbar treats that toggle. Also added show_feedback_to_guests
  to the local feedbackSettings TS type (backend already returns it).

Tests: tsc --noEmit clean. No new unit tests — bug 1 is data-flow
plumbing best validated via manual / e2e (the existing my-feedback
endpoint and feedbackService are already covered upstream); bugs 2/3
are CSS and a conditional render.

Refs: #538 (bugs 1, 2, 3 of 4)
2026-05-20 16:42:22 +02:00
rpintodasilva dae47518fb fixes 2026-05-20 13:13:35 +02:00
rpintodasilva fd15be6247 French Transalation - v2 2026-05-20 10:40:24 +02:00
Paul Nothaft cf10da29ca Merge pull request #536 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.1-beta.0
2026-05-20 08:54:14 +02:00
github-actions[bot] baf5f27fb4 chore(beta): release 3.54.1-beta.0 2026-05-20 06:53:31 +00:00
Paul Nothaft 83fc58a523 Merge pull request #535 from the-luap/fix/public-site-dark-theme-contrast
Fix public site contrast for dark themes
2026-05-20 08:53:02 +02:00
paul 8b72721812 fix(public-site): honor dark theme surface colors 2026-05-20 08:48:40 +02:00
Paul Nothaft e0ad7ac2a7 Merge pull request #534 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.54.0-beta.0
2026-05-20 08:24:46 +02:00
github-actions[bot] 085a2e5fed chore(beta): release 3.54.0-beta.0 2026-05-20 06:24:22 +00:00
Paul Nothaft a0ebc97cdd Merge pull request #533 from the-luap/feat/schema-drift-test-530
fix(install): skip legacy chain on recovery-state DBs + schema-drift CI (#530)
2026-05-20 08:23:57 +02:00
Paul Nothaft 4d3f2470bc ci(schema-drift): handle absent migrations table in precondition (#530)
First CI run failed at the precondition check because the SQL `CASE WHEN
to_regclass(...) IS NULL THEN 0 ELSE (SELECT count(*) FROM migrations)`
expression doesn't short-circuit at parse time — Postgres parses the
subquery against `migrations` even when the outer guard would skip it,
fails the run with "relation 'migrations' does not exist".

initializeDatabase() doesn't create the `migrations` tracking table —
that's the migrate:safe runner's responsibility — so in the recovery
scenario the table genuinely doesn't exist yet. Both "absent table" and
"present but empty table" are valid recovery states.

Split the check into two shell steps: to_regclass first, then count only
if the table exists. Avoids the parse-time subquery error and accepts
either state.
2026-05-19 22:53:30 +02:00
Paul Nothaft 8f0108ce23 feat(install): skip legacy chain when modern bootstrap fingerprint detected (#530)
Refined from the original #530 framing after a dry-run uncovered that the
"bootstrap vs migration chain" diff produces mostly noise — most of the
~200 lines of difference are expected (migrations add new tables and
columns over time). initializeDatabase() isn't a parallel path that
diverges from migrations; it's invoked by migration 001 itself, so every
normal install/upgrade runs both.

The genuine drift hazard surfaced during the dry-run: a DB with the
modern bootstrap tables but an empty `migrations` table (which happens
when a backup was restored that lost the migrations table, or someone
invoked initializeDatabase() outside the runner, or the DB was moved
between systems without copying the migrations row) fails to upgrade.

Failure mode:
  1. detectExistingSchema sees the bootstrap tables + empty migrations,
     treats it as an "existing deployment".
  2. Runs the legacy chain first.
  3. legacy/008 renames email_templates.subject → subject_en.
  4. core/029 (later in the chain) inserts email templates referencing
     the pre-rename `subject` column.
  5. Postgres rejects: column "subject" doesn't exist; subject_en is
     NOT NULL with no default.

Fresh installs avoid this because they only run core/* (and core/059
handles the rename AFTER core/029 has inserted). Real legacy upgrades
avoid it because their migrations table already records legacy/008–028
as applied historically.

Fix in detectExistingSchema:
  - Detect the modern bootstrap fingerprint (photo_categories + cms_pages
    both present, which initializeDatabase produces as part of the
    consolidated post-004-era bootstrap).
  - When matched, enumerate every file in migrations/legacy/ and mark
    each as applied. This puts the recovery state on the same code path
    fresh installs use — only core migrations run, in core order.
  - Real legacy upgrades that already have entries in the migrations
    table hit no-op markings (markMigrationAsApplied skips duplicates),
    so their behaviour is unchanged.

New CI workflow (`.github/workflows/schema-drift.yml`):
  - Boots fresh postgres.
  - Seeds via `node -e \"require('./src/database/db').initializeDatabase()\"`
    — reproduces the recovery state in one line.
  - Runs `npm run migrate:safe`.
  - Asserts: precondition (bootstrap fingerprint + empty migrations
    table), migrate:safe exits 0, final schema has ≥40 tables (soft floor,
    not exact pin so future migrations don't force workflow edits),
    legacy migrations marked applied (confirms the fingerprint check
    actually fired vs. the chain silently bailing).
  - Triggers only on PRs that touch backend/migrations/**,
    src/database/db.js, knexfile.js, or this workflow.

Manually verified end-to-end before this commit:
  Before fix:  migrate:safe dies at core/029 with NOT NULL violation
               on email_templates.subject_en (17/48 tables present).
  After fix:   82 migrations applied + 27 marked applied = 109 total,
               final state has all 48 tables matching fresh-install.

Issue body in #530 has been updated to match this refined scope.

Refs: #530, #484, #519
2026-05-19 22:48:54 +02:00
Paul Nothaft bdd973eaf9 Merge pull request #529 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.53.0-beta.0
2026-05-19 07:28:26 +02:00
github-actions[bot] c042a33431 chore(beta): release 3.53.0-beta.0 2026-05-19 05:27:14 +00:00
Paul Nothaft 633a2ae724 Merge pull request #527 from the-luap/fix/bug-batch-518
fix(bug-batch-518): lightbox comments toggle + further fixes
2026-05-19 07:26:54 +02:00
Paul Nothaft e8c2212dad refactor(slug): extract shared slugify util + scope adminPhotos category lookup (#525)
Folds all three follow-up items tracked in #525 into one commit:

1. Mirror PR #500's category scoping on adminPhotos.js. The admin
   upload route at adminPhotos.js:231 still accepted any category_id
   without event scoping — quietly less strict than the public v1
   API after #500 landed. Same one-liner fix (event_id OR is_global)
   with a matching 400 response shape so admin + v1 stay consistent.

2. Extract a shared slugify() in backend/src/utils/slug.js with the
   NFD-strip-combining-marks fix from #502, and route 5 callers
   through it:
     - adminEvents.js (event-name slug)
     - events.js      (event-create slug)
     - v1/events.js   (replaces local slugify helper)
     - adminArchives.js (archive→category slug)
   For pure-ASCII input the output is byte-identical to each old
   inline pipeline, so existing slugs in the DB keep round-tripping
   cleanly via lookup. Accented inputs now transliterate (Família
   → familia) instead of dropping the diacritic (Família → f-mlia).
   adminCategories.js stays with its own pipeline (underscores-as-
   word-chars semantics differ from the events-style transform —
   changing would silently shift wedding_party → wedding-party on
   new inserts). xmpGenerator.sanitizeKeyword stays unchanged for
   the same compat-cautious reason.

3. Cover the v1 upload happy path. Existing test only exercised the
   400-out-of-scope branch. Add two happy-path cases that stub
   sharp / generateThumbnail / storage.putFromFile and pin the
   response shape (id, category_id, type, etc.) plus the collage-
   slug → type='collage' flip. Temp file recreated in beforeEach
   because the handler unlinks it on success.

Tests:
- New slug.test.js: 22 cases pinning ASCII parity with the legacy
  pipeline (so the refactor is provably non-breaking for existing
  data) and the corrected accent handling across de/es/fr/nl/pt
  inputs, plus CJK and edge-case behaviour.
- events.category.test.js: 4 tests total (2 existing + 2 new happy
  path).
- galleryOgService.shareImage.test.js: 11 (3 added in #521 + 8 pre-
  existing) still pass.

37 tests pass across the three touched files.

Refs: #525, follows up #500 and #502
2026-05-18 23:50:35 +02:00
Paul Nothaft 4b4ecfdf71 fix(header): hide language name on mobile to free the title (#523)
@Rekoo-PS reported the LanguageSelector pushing into the company-name
title on narrow viewports — the button always rendered
Globe + flag + full language name (~120px), and on mobile that pinched
the left-side title cluster in AdminHeader.

Wrap the name in `hidden sm:inline` so <sm the button collapses to
just Globe + flag, matching the existing "hidden xl:block" pattern
on the date display in the same header. Self-explanatory at icon-only
width (users see their current flag and a globe), and the dropdown
still shows full names when opened. Title/aria-label keep the name
discoverable for screen readers + tooltip hover on the icon-only state.

Refs: #523
2026-05-18 23:19:42 +02:00
Paul Nothaft b960639035 fix(og): brandable static title + wider crawler UA coverage (#521)
@Rekoo-PS reported that gallery URLs sent via the WhatsApp Business
API render an unbranded "PicPeak - Photo Sharing Platform" preview
even though manual link sends from the WhatsApp app pick up the
per-event rich preview correctly. Two root causes, two fixes:

1. WhatsApp Business and 3rd-party preview services (Twilio,
   LinkPreview.net, etc.) don't always crawl with the recognisable
   "WhatsApp/X.Y.Z" UA we matched in nginx + galleryOgService.
   Extend the regex (both copies) to also catch WhatsAppBot, wa-bot,
   LinkPreview, and Slack-ImgProxy.

2. Even with broader UA coverage, some senders cache metadata with
   no UA at all and fetch the static SPA shell. That shell's
   <title> was hard-coded to "PicPeak - Photo Sharing Platform" —
   embarrassingly generic for any self-hosted brand. Switch to
   Vite's %VITE_DEFAULT_TITLE% / %VITE_DEFAULT_DESCRIPTION% HTML
   substitution so self-hosters can bake their brand into the
   fallback at build time. Defaults stay "PicPeak" so the upstream
   image doesn't change behaviour for anyone.

The per-event rich preview path (handleGalleryOgRequest, fired on
matched crawler UAs) is unchanged — this only improves the fallback
for unrecognised UAs and for the SPA-shell title that humans see in
their browser tab.

Adds a vite.config plugin to provide the defaults when env vars
aren't set, so unsubstituted "%VITE_..." literals never reach the
built HTML. Adds .env.example entries explaining the override.

Tests: extend galleryOgService.shareImage.test.js with an
isSocialCrawler suite that pins every documented UA (incl. the new
ones) plus three browser UAs (negative) and null/empty edge cases.
Verified locally: `vite build` with VITE_DEFAULT_TITLE="MyBrand"
produces <title>MyBrand</title> + og:title="MyBrand"; without the
env var falls back to "PicPeak".

Refs: #521
2026-05-18 22:45:00 +02:00
Paul Nothaft 3465b55abc feat(events): default Guest Feedback ON via admin setting (#520)
@Rekoo-PS asked for an admin-level switch so new events can have Guest
Feedback enabled out of the box instead of toggling it on every time.
Mirrors the existing event_default_require_password pattern (#317) —
same shape end-to-end, same set of five files.

- publicSettings.js: whitelist + expose event_default_feedback_enabled
  (defaults to false to match the prior hard-coded form default; no
  behaviour change for existing installs until an admin flips it).
- adminEvents.js: rename `feedback_enabled = false` destructure to
  `feedback_enabled: feedbackEnabledInput` so we can distinguish
  "omitted" from "explicit false", then resolve the default from the
  setting only when the caller omitted it — identical to the
  require_password handling a few lines above.
- Frontend EventSettings type + state + loader: new boolean,
  default false.
- EventsTab: toggle UI right under "Require password by default".
- CreateEventPage: one-shot useEffect that seeds
  feedback_settings.feedback_enabled from the public setting on first
  load (mirrors the require_password seed effect right above it).
  Sub-toggles (likes / ratings / comments) keep their hard-coded
  true defaults so flipping the master setting immediately gives
  sensible behaviour without a second admin setting to manage.

Refs: #520
2026-05-18 21:26:45 +02:00
Paul Nothaft d44e1adba7 fix(lightbox): hide comments toggle when allow_comments=false (#518)
@Rekoo-PS reported the MessageSquare comment button stayed visible in
the lightbox toolbar even when guest comments were disabled. Same
class of bug as #513 (per-photo Like button missing the master
gate) but on a different control.

The Like and Rating buttons in the lightbox toolbar gate correctly:
  feedbackEnabled && feedbackSettings?.allow_likes
  feedbackEnabled && feedbackSettings?.allow_ratings

The MessageSquare button only checked feedbackEnabled. Since likes
and ratings already have their own inline buttons in the same
toolbar, this third button is effectively the "open comments panel"
affordance — its badge counts comments, its tooltip mentions
comments. When comments are off it has nothing meaningful to do.

Add allow_comments to the local feedbackSettings type (the backend
already returns it via galleryFeedback.js:33) and gate the button on
feedbackEnabled && feedbackSettings?.allow_comments.

Refs: #518
2026-05-18 21:13:03 +02:00
Paul Nothaft 7b8ee1c148 Merge pull request #526 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.52.1-beta.0
2026-05-18 21:04:37 +02:00
github-actions[bot] b2b46d311d chore(beta): release 3.52.1-beta.0 2026-05-18 18:59:57 +00:00
Paul Nothaft 42c5cda38c Merge pull request #519 from the-luap/fix/install-permissions-484
fix(install): self-chowning entrypoint kills fresh-install restart loop (#484)
2026-05-18 20:59:33 +02:00
Paul Nothaft 91d47590ae Merge pull request #524 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.52.0-beta.0
2026-05-18 20:59:14 +02:00
github-actions[bot] db3f1b83ce chore(beta): release 3.52.0-beta.0 2026-05-18 18:56:50 +00:00
Paul Nothaft 2d5a2ad78a Merge pull request #500 from munin92/feat/v1-upload-category-id
feat(api/v1): accept category_id on POST /events/:id/photos
2026-05-18 20:56:17 +02:00
Paul Nothaft 763fd4593f ci(install-smoke): use BusyBox-compatible ps in node-user check
Alpine ships BusyBox ps (no -p PID, no pgrep), which failed CI on the
first run of this workflow with "ps: unrecognized option: p". Replace
the pgrep-then-ps chain with `ps -o user,comm | awk '$2=="node"'`
which works on both BusyBox (Alpine, in the container) and procps
(the GitHub runner host, though we don't use it here).
2026-05-18 10:28:29 +02:00
Marian df83b3e923 test(api/v1): cover category scoping clause + 400 response
Unit test for the v1 upload route's category lookup, requested in
the PR review. Mocks db (chainable, mirroring src/routes/__tests__/
adminAuth.test.js) plus apiTokenAuth/requireApiScope (pass-through)
and multer (stub req.file). Two cases:

1. The scoping clause: the andWhere callback applied to a knex
   builder spy produces .where({event_id: <event.id>}).orWhere(
   'is_global', true) — exactly the contract the reviewer asked
   for, exercising the OR-clause rather than just asserting the
   callback was passed.
2. Null lookup result yields 400 with "Unknown or out-of-scope
   category_id <N>".

No v1 jest scaffolding existed before, but the project-wide harness
(backend/jest.config.js + jest.setup.js) already covers the new
file via testMatch '**/__tests__/**/*.test.js'. Happy-path tests
deferred — would require stubbing fs/sharp/imageProcessor/share
linkService and several more db chains, which the reviewer was
willing to accept as a separate follow-up.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:21:43 +00:00
Marian 92bb9e1a12 fix(api/v1): scope category lookup to event_owned or global
PR review pointed out the original lookup
  db('photo_categories').where({ id: parsedCategoryId }).first()
accepted any category id — including one that belongs to a different
event. photo_categories carries both event_id (per-event) and is_global
(see backend/migrations/legacy/004_add_categories_and_cms.js); the v1
upload route should require either match.

Not a privilege issue (apiTokenAuth.js inherits the admin's powers, no
per-event scoping), but it lets a misconfigured uploader silently file
photos under a category the target event doesn't own — and the 201 echo
includes a category_id that makes no semantic sense.

Tighten to:
  .where({ id: parsedCategoryId })
  .andWhere(function () {
    this.where({ event_id: event.id }).orWhere('is_global', true);
  })
…and update the 400 message to "Unknown or out-of-scope category_id N".

OpenAPI description already documents the intended scope.

Tests deferred to a follow-up; v1 has no jest harness today, see PR
discussion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:21:43 +00:00
Marian 6901e2661e feat(api/v1): accept category_id on POST /events/:id/photos
The v1 photo upload endpoint previously ignored any caller-supplied
category and inserted photos with category_id=NULL. That meant
programmatic uploads via API tokens (e.g. a photobox sidecar) landed
in picpeak as uncategorized, forcing operators to bulk-assign category
in the admin UI after each event.

Mirror the adminPhotos.js category-handling logic on v1:
- Read optional `category_id` from the multipart form body.
- Reject unknown ids with 400 (with the id in the error) so callers
  fail fast on misconfigured envs instead of silently uncategorized
  uploads.
- Set photos.category_id on insert.
- Flip photos.type to 'collage' when the category's slug is
  collage/collages, matching adminPhotos.

Backwards-compatible: omitting category_id keeps the prior behavior
(insert with NULL category, type='individual'). OpenAPI spec + 201
response body updated to include the new field.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 07:21:43 +00:00
Paul Nothaft 1505775678 fix(install): self-chowning entrypoint kills fresh-install restart loop (#484)
The fresh-install restart loop reported by @MrGabri (and confirmed by
@AloePacci with the user:0:0 workaround) had a clear root cause:

  - Dockerfile pinned USER nodejs (UID 1001) before the entrypoint
    ran, so the existing chown branch in init-production.sh:13 was
    dead code.
  - wait-for-db.sh (the actual entrypoint, not init-production.sh)
    silently swallowed mkdir/EACCES on bind mounts with || true,
    then a downstream migration error surfaced as the visible failure.
  - Net effect on a typical Linux host where the bind-mount dir is
    owned by UID 1000: container can't write, exits non-zero,
    restarts forever with no clear error.

Switch to the standard Docker drop-privileges pattern:

  1. Install su-exec, drop `USER nodejs` from the Dockerfile —
     container now starts as root.
  2. wait-for-db.sh: if running as root, chown /app/storage,
     /app/data, /app/logs to nodejs and re-exec self via
     su-exec nodejs:nodejs. App still ends up running as UID 1001.
  3. Preflight check for non-root invocations (compose `user:`
     overrides): verify the bind mounts are actually writable
     before continuing. If not, exit 1 immediately with an
     actionable error pointing at the docs — no more silent
     restart loops.

Also:

  - Delete backend/init-production.sh. It was an orphan — no caller
    in the Dockerfile, compose, or anywhere else. Its chown logic
    looked authoritative enough that @MrGabri ran it manually trying
    to debug, which is what finally surfaced the EACCES.
  - docker-compose.yml: drop user: + PUID/PGID env. The pattern-B
    UID-matching workaround they implemented is obsolete now that
    pattern A (root-then-drop) is in place.
  - .env.example + README: drop PUID/PGID documentation.
  - Add fresh-install smoke test workflow. Boots backend + postgres
    against bind mounts owned by UID 1000 (the GitHub runner UID,
    and the common-mismatch case on Linux hosts) and verifies:
    + container reaches healthy without restart-looping
    + chown happened (dirs now owned by 1001 inside the container)
    + node runs as nodejs, not root (su-exec drop worked)
    + /health returns status:ok
    + with --user 5005:5005 + unwritable mounts, preflight exits
      loud with the expected error string

Verified locally end-to-end against a fresh Postgres + UID-501-owned
bind mount: backend reaches healthy in ~20s, chown applied, node
runs as nodejs, no restart loop. Docs in picpeak-docs cover the new
behavior + a Troubleshooting section for the install-path bugs
fixed in #484/#494/#511/#488.

Refs: #484
2026-05-17 22:29:29 +02:00
Paul Nothaft 2619049a95 Merge pull request #517 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.5-beta.0
2026-05-17 09:29:01 +02:00
github-actions[bot] acb387f69c chore(beta): release 3.51.5-beta.0 2026-05-17 07:28:47 +00:00
Paul Nothaft ebc7da21be Merge pull request #503 from filpgame/fix/email-language-json-parse
fix(email): parse JSON-encoded language setting before using as locale
2026-05-17 09:28:25 +02:00
Paul Nothaft afcdf0d389 Merge pull request #516 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.4-beta.0
2026-05-17 09:27:19 +02:00
github-actions[bot] 4da6726e57 chore(beta): release 3.51.4-beta.0 2026-05-17 07:26:57 +00:00
Paul Nothaft a747eb351d Merge pull request #502 from filpgame/fix/category-slug-diacritics
fix(categories): strip diacritics from auto-generated slugs
2026-05-17 09:26:39 +02:00
Paul Nothaft 4fc07d9282 Merge pull request #515 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.3-beta.0
2026-05-17 01:20:56 +02:00
github-actions[bot] 941453fd7f chore(beta): release 3.51.3-beta.0 2026-05-16 23:19:50 +00:00
Paul Nothaft 482e91bbf8 Merge pull request #501 from filpgame/fix/settings-page-language-reset
fix(i18n): settings page resets UI language to server default
2026-05-17 01:19:27 +02:00
Paul Nothaft aa8cca165d Merge pull request #514 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.2-beta.0
2026-05-17 01:02:48 +02:00
github-actions[bot] 5a9ca4ad57 chore(beta): release 3.51.2-beta.0 2026-05-16 23:02:10 +00:00
Paul Nothaft aac60fa895 Merge pull request #513 from the-luap/fix/bug-batch
fix/feat: bug batch — drag-drop, lightbox, likes, downloads, upload, i18n (#504-510)
2026-05-17 01:01:49 +02:00
Paul Nothaft 51890e1aa5 fix(i18n): drive customer "Preferred language" select from SUPPORTED_LANGUAGES (#510)
Audit follow-up to the Spanish-locale commit: `CustomerDetailPage` had
a hardcoded `<option>` list for the customer's preferred-language
selector — 5 entries (en/de/nl/pt/ru) that were missing both fr (an
existing gap) and es (the new one). Every other language selector in
the frontend (the navbar `LanguageSelector`, the `GeneralTab` default-
language dropdown, the `EmailConfigPage` per-language tabs) already
reads from the shared `SUPPORTED_LANGUAGES` constant, so adding es
there was enough for those. This one had drifted.

Now mapped from `SUPPORTED_LANGUAGES` so future locales only need to
touch one place.
2026-05-17 00:57:03 +02:00
Paul Nothaft 1e7806961f Merge pull request #512 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.1-beta.0
2026-05-17 00:53:34 +02:00
github-actions[bot] 2e828fcf9f chore(beta): release 3.51.1-beta.0 2026-05-16 22:53:14 +00:00
Paul Nothaft 99e60a2433 Merge pull request #511 from the-luap/fix/install-postgres-log-noise
fix(install): silence clean-install postgres log noise (#484)
2026-05-17 00:52:48 +02:00
Paul Nothaft 061712ebf1 feat(i18n): add Spanish (es) locale (#510)
Contributed by @AloePacci on issue #510. Drops their es.json into the
existing locale set, registers Spanish in the language selector with a
flag SVG matching the inline style of the other six locales, and
extends the email pipeline so es-language guests receive a localised
email subject/body where available.

Coverage:
- frontend/src/i18n/locales/es.json — 2132 translated keys. ~824
  EN keys are not yet covered; i18next's `fallbackLng: 'en'` handles
  those at runtime so the UI never renders a missing key. fr/nl/pt/ru
  have a similar (smaller) gap and ship the same way.
- LanguageSelector.tsx — added the ESFlag inline SVG (red/yellow/red
  horizontal bands, official #AA151B + #F1BF00; no coat of arms to
  stay consistent with the other simple flag components) and a new
  entry in SUPPORTED_LANGUAGES.
- emailProcessor.js — added .es to the domain-language heuristic, and
  an `es:` row to the three inline-translated snippets
  (passwordSecurityI18n / noPasswordI18n / passwordSetAtCreationI18n).
- 106_seed_es_email_template_translations.js (new) — idempotent
  seeder for the four customer-facing templates AloePacci translated:
  gallery_created, expiration_warning, gallery_expired, archive_complete.
  Mirrors the pattern from 099. Template keys without an `es` row fall
  back to `en` via the existing resolution chain in
  emailProcessor.processTemplate — no functional gap, just untranslated
  copy until someone fills them in.

What I deliberately did NOT take from the contribution: the proposed
in-place edit of migration 075 (history mutation — won't reseed for
existing installs anyway) and the whitespace/`gallery_list_html`-drop
churn in emailProcessor.js (would have regressed the #354 follow-up).
The semantic additions from those files are preserved via 106 and the
targeted edits above.
2026-05-17 00:50:51 +02:00
Paul Nothaft 98f3c3df41 fix(upload): restore configurable batch-size for reverse proxies (#509)
Regression of #208. PR #214 (commit 02a46e0, re-merged at 9b7495e)
shipped the configurable `general_max_upload_batch_size_mb` setting so
users behind Cloudflare Tunnel and other reverse proxies with
per-request size caps could lower the chunked-upload size below their
proxy's limit. Six days later the "Merge main into beta for
release/beta-to-main" commit (28793bb) resolved its conflict by
keeping main's older tree — which silently deleted the migration
(072), the setting input on Settings → General, the i18n strings, the
`useSettingsState` field, and the read in PhotoUpload.tsx, putting the
hardcoded 500MB chunk back. Galleries fronted by Cloudflare have
quietly been broken on batch uploads since then.

Re-applying exactly the same change set:

- `backend/migrations/core/072_add_max_upload_batch_size.js`
  recreated, with a comment pointing at the regression in case the
  same merge accident happens again.
- `frontend/src/components/admin/PhotoUpload.tsx` line 168 now reads
  the setting from query cache and falls back to 95MB (Cloudflare-safe
  headroom under 100MB).
- `useSettingsState.ts`, `GeneralTab.tsx`, `en.json`, `de.json` —
  added the field to the state type + defaults + load path + the
  Site-Configuration input.

Existing installs are safe either way:
- Ran original 072 then lost the file: migrations table still has the
  filename, so the runner skips re-applying. The setting row in
  `app_settings` is also untouched (the deletion was source-only, no
  down migration ran). Now the new code starts reading it again.
- Installed after the regression: migrations runner picks up the new
  072 normally and seeds the setting at 95.
2026-05-17 00:42:22 +02:00
Paul Nothaft 33de294d57 feat(lightbox): surface original camera filenames (#508)
Photographers running the gallery as a client-selection tool want to
map a guest's picks back to source files for retouching. The
`general_use_original_filenames_for_downloads` toggle (#493) already
does this on the download side; this extends the same toggle to the
in-lightbox view so the camera filename is visible alongside the
photo while it's being looked at.

Tied to the same toggle on purpose — one switch controls both
surfaces. Off by default; existing galleries keep showing only the
position counter.

Wiring:
- gallery.js serializes `photos[].original_filename` and surfaces the
  resolved toggle as `event.use_original_filenames` so the client can
  decide whether to render it.
- The bespoke `PhotoLightbox` renders the original filename (falling
  back to the storage filename only for pre-migration-062 uploads) in
  a muted line under the position counter, truncated to keep the
  toolbar tidy.
- `GalleryStoryLayout` mounts the same `PhotoLightbox`, so its
  rendering follows along.
- `GalleryPremiumLayout` uses `yet-another-react-lightbox` instead;
  added the Captions plugin and a `title` field on the slides so the
  same name appears as a caption when the toggle is on.

The remaining layouts feed back into the main `PhotoLightbox` via
`PhotoGridWithLayouts`, so the prop reaches them through the layout
props bag.
2026-05-17 00:35:16 +02:00
Paul Nothaft 38343e62de fix(downloads): apply original-filename toggle to individual downloads too (#507)
Follow-up to #498. The toggle reached zip downloads but single-photo
downloads still landed on disk with the renamed `event_individual_NNN.jpg`
even when the admin had flipped the setting on. Two reasons, fixed
in lockstep:

- Frontend overrode the server's Content-Disposition with a hardcoded
  `<a download="X">` attribute (`gallery.service.ts`, `photos.service.ts`)
  where X was the sanitized `photo.filename` known to the client. So
  the backend's correctly-formed `Content-Disposition` never reached
  the disk write. Added `parseContentDispositionFilename` (RFC 5987 +
  plain `filename=` fallback) and let the server name win when present.
- `secureImages.js` (enhanced/maximum protection's secure-download
  route) was missed in #498 and still emitted a hardcoded
  `filename="${photo.filename}"` regardless of the toggle. Wired it
  through `getUseOriginalFilenames` + `buildContentDisposition` so it
  matches the regular gallery download path.

Also exposed `Content-Disposition` via CORS so split (cross-origin)
frontend deployments can still read it from JavaScript. Same-origin
Docker deploys already had access; this is a defensive addition for
the split case.
2026-05-17 00:25:12 +02:00
Paul Nothaft 9d2db9a73b fix(gallery): hide Like button when guest feedback is off (#506)
Four gallery layouts were rendering the per-photo Like button without
gating on the master "Guest Feedback" toggle, so a guest still saw a
heart icon and could submit likes on events where the host had turned
feedback off. The other layouts (Grid / Justified / Masonry / Story)
already gated correctly with `feedbackEnabled && allowLikes` —
Rekoo-PS's note that "it's hidden in some themes" matches that split.

- CarouselGalleryLayout, MosaicGalleryLayout, TimelineGalleryLayout:
  the existing conditional checked only `feedbackOptions?.allowLikes`,
  missing the `feedbackEnabled` master gate. Added it inline.
- GalleryPremiumLayout: the per-card Like button rendered
  unconditionally because PhotoCard never received the allow-likes
  signal. Added an `allowLikes` prop on PhotoCardProps, plumbed
  `feedbackOptions?.allowLikes` down from the parent, and wrapped the
  button in `feedbackEnabled && allowLikes`.

The follow-up "default guest-feedback ON" request from Rekoo-PS in
the comments is a separate feature (admin > General > Event Creation
default) and out of scope for this fix.
2026-05-17 00:13:21 +02:00
Paul Nothaft d2d55098d6 fix(lightbox): align swipe-neighbour height + stop black flash on commit (#505)
Two adjacent swipe-time defects, one diagnosis each:

1. Height differed between current and neighbouring slides during a
   swipe but matched when the arrow buttons advanced the carousel.
   Cause: neighbour slides wrap their image in a div with extra `px-2`
   horizontal padding while the current slide does not. `object-contain`
   then sees a narrower container on neighbours, so wide images cap on
   width first and render shorter than the same image at the current
   position. Removed the padding so both slots share the same container
   geometry. Arrow-button navigation looked fine because it never
   showed the neighbour layout side-by-side.

2. The image flashed black for ~100–400 ms each time a swipe committed
   to the next slide. Cause: the 3-slide track has no React keys, so
   React reconciled slides by position. After commit the photo at every
   position changed (`prev → current → next` shifts left), every slot's
   `<AuthenticatedImage>` saw a new `src` prop, and its fetch effect
   restarted from the placeholder state — including the slot that was
   the user's "next" slide a moment ago and held a fully-loaded image.
   Added a stable `key` derived from `photo.id` so React MOVES existing
   DOM nodes across slots instead of refetching. 2-photo galleries are
   a key-collision edge case (`prev === next`), so they fall back to
   slot-prefixed keys to keep siblings unique; behaviour there is no
   worse than today.
2026-05-17 00:09:15 +02:00
Paul Nothaft 577c4bdf29 fix(upload): wire drag-and-drop on admin + user upload zones (#504)
The dashed-border upload area in `PhotoUpload` (admin) and
`UserPhotoUpload` (gallery user-upload) is styled and labelled as a
drop zone — every locale's `upload.clickToUpload` already reads
"Click to upload or drag and drop" or its translation — but neither
component had any `onDragOver` / `onDragEnter` / `onDragLeave` /
`onDrop` handlers. Files dropped on the zone fell through to the
browser's default behaviour (open the image in a new tab), which is
what Rekoo-PS reported.

Added native HTML5 drag-and-drop wiring on both components, plumbed
through the same filter/limit/toast pipeline used by the click path
(`addFiles` helper). Visual highlight on drag-over via an `isDragOver`
flag; the listener guards against the `dragleave` strobing that fires
on every child node. Also reset the `<input>` value after onChange so
re-picking the same file still triggers an upload — matches the
new drop-then-pick mental model.
2026-05-17 00:02:39 +02:00
Paul Nothaft 86b33d4dda fix(install): silence clean-install postgres log noise (#484)
Two latent install-time issues that emitted scary postgres ERROR lines
on every fresh start but didn't actually break anything. MrGabri flagged
them after #494 had already cleared the FK-ordering crash.

1. Migration 035 builds three `CREATE INDEX` statements against
   `backup_runs(created_at, …)`, but 029 creates the table with
   `started_at` and no `created_at`. The wrapping try/catch silently
   swallowed the resulting `column "created_at" does not exist` ERROR,
   so the migration "succeeded" without ever creating the indexes.
   Switched 035 to reference `started_at` (same chronological semantics)
   and added migration 105 to create the same indexes idempotently for
   deployments whose 035 already ran and silently failed.

2. `run-migrations-safe.js` snapshots `appliedFilenames` *before*
   `detectExistingSchema()` runs. When `detectExistingSchema()` inserts a
   row for e.g. `004_add_categories_and_cms.js` (because its tables exist
   from a partially-completed prior install), the subsequent migration
   loop still doesn't know about that insert, attempts the legacy
   migration anyway, and its transaction-internal
   `insert into migrations` conflicts with the row already there.
   Re-query the applied set after detectExistingSchema so the loop sees
   the corrected snapshot.

No behavioural change for healthy installs. New installs no longer log
the `column "created_at" does not exist` or `duplicate key value
violates unique constraint "migrations_filename_unique"` ERRORs.
2026-05-16 23:56:05 +02:00
filpgame f12062f1e7 fix(email): parse JSON-encoded language setting before using as locale
general_default_language is stored as a JSON string (e.g. "\"pt\"").
getRecipientLanguage() returned the raw value including quotes, causing
the translation lookup to miss every match and fall back to English.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 03:31:15 -03:00
filpgame 848430e72b fix(categories): strip diacritics from auto-generated slugs
Accented chars (ã, ç, é, etc.) were silently dropped by the slug
regex because \w only matches ASCII. NFD decomposition + combining
mark removal converts them to ASCII equivalents instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-16 02:23:03 -03:00
filpgame 165ebce8d1 fix: settings page resets UI language to server default
When navigating to the Settings page, useSettingsState called
i18n.changeLanguage() with the server-stored general_default_language
value on every settings query resolution. This caused the admin UI
language to reset to the server default (e.g. "en") regardless of the
language the user had selected via the LanguageSelector.

The general_default_language setting is intended as the default for
public galleries, not for controlling the admin UI language. The admin
UI language is already persisted via localStorage through
i18next-browser-languagedetector and should not be overridden by server
settings.

Remove the i18n.changeLanguage() call from the useEffect that
initialises settings state from the API response.
2026-05-16 01:14:57 -03:00
Paul Nothaft 3a490844e1 Merge pull request #499 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.51.0-beta.0
2026-05-14 23:30:58 +02:00
github-actions[bot] 72c2b5c796 chore(beta): release 3.51.0-beta.0 2026-05-14 21:25:07 +00:00
Paul Nothaft 826e43ebac Merge pull request #498 from the-luap/feat/lightbox-preview-tier-492
feat(downloads): preserve original camera filenames on download (opt-in) (#493)
2026-05-14 23:24:41 +02:00
Paul Nothaft 019b0c0301 Merge pull request #497 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.50.0-beta.0
2026-05-14 23:22:44 +02:00
Paul Nothaft 7eeef2ba98 feat(downloads): preserve original camera filenames on download (opt-in) (#493)
New Settings → General toggle `Use original filenames on download` (off by
default). When on, single-photo downloads, bulk/selection zips, and per-event
archive zips surface `photos.original_filename` instead of the sanitized
storage filename. Storage paths are unchanged.

- Content-Disposition uses RFC 5987 (`filename=` ASCII + `filename*=UTF-8''…`)
  so unicode camera filenames survive while header-injection bytes are stripped.
- Zip entries are deduplicated with a deterministic `_1` / `_2` suffix on
  collision (folder structure preserved in archive zips).
- Pre-generated download-all zips and the in-memory setting cache are
  invalidated when the toggle flips so the next download rebuilds with the
  new names.
- Falls back to the storage filename whenever `original_filename` is null
  (legacy uploads predating migration 062).
2026-05-14 23:11:00 +02:00
github-actions[bot] 365582e678 chore(beta): release 3.50.0-beta.0 2026-05-14 20:44:57 +00:00
Paul Nothaft 3083c748b9 Merge pull request #496 from the-luap/feat/lightbox-preview-tier-492
feat(lightbox): medium-resolution preview tier (#492)
2026-05-14 22:44:35 +02:00
Paul Nothaft 61f1d13210 feat(lightbox): medium-resolution preview tier (#492)
Adds an opt-in lightbox preview tier so guests open photos against an
aspect-preserved ~1920px JPEG (~200–500 KB) instead of the full original
(often 5–12 MB). Originals are still served on Download.

Backend:
  - imageProcessor: generatePreviewImage / isPreviewValid / ensurePreviewImage
    using fit:'inside' + withoutEnlargement (longEdge 1920, q85, mozjpeg)
  - migration 104: photos.preview_path + lightbox_preview_enabled setting
    (off by default, JSON-stringified for SQLite/Postgres parity)
  - GET /api/gallery/:slug/preview/:photoId — gallery-auth, lazy generation,
    ETag based on mtime+photoId+watermarkHash
  - preview_url surfaced in the photo response only when the toggle is on
  - admin /thumbnails/regenerate-previews mirrors regenerate-thumbnails,
    skipping videos
  - backup walk + archive cleanup + photo-delete now include previews/

Frontend:
  - PhotoLightbox uses photo.preview_url ?? photo.url (null-safe fallback)
  - ThumbnailsTab gets a Lightbox Preview Tier card: opt-in toggle +
    Regenerate All Previews button (gated until the toggle is on)
  - en/de locale strings; nl/pt/ru/fr fall back to en

Tested end-to-end: 11 MB / 4000×3000 source → 985 KB / 1920×1440 preview,
381 ms first call, 7 ms cached, ~91% byte reduction.
2026-05-14 22:30:39 +02:00
Paul Nothaft 06b33ced94 Merge pull request #495 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.6-beta.0
2026-05-14 21:37:55 +02:00
github-actions[bot] d4198e1bbc chore(beta): release 3.49.6-beta.0 2026-05-14 19:37:31 +00:00
Paul Nothaft 62b3ed6364 Merge pull request #494 from the-luap/fix/postgres-fresh-install-fk-order
fix(install): defer events.hero_photo_id FK to break circular reference (#484)
2026-05-14 21:37:00 +02:00
Paul Nothaft 87834a7fff fix(install): defer events.hero_photo_id FK to break circular reference (#484)
Real root cause behind MrGabri's fresh-Postgres install crash, surfaced
by his second log dump after #488 silenced the FATAL noise:

  Initial setup failed: error: alter table "events" add constraint
  "events_hero_photo_id_foreign" foreign key ("hero_photo_id")
  references "photos" ("id") on delete SET NULL
  - relation "photos" does not exist

initializeDatabase() in src/database/db.js declared the FK inline at
events createTable (line 89), but the photos table is created later
in the same function (line 203). On Postgres this is a hard error —
the referenced table must exist at FK-declaration time. SQLite
silently tolerated it because its FK enforcement is lazy and the
inline declaration just became a column with no FK metadata.

Why no existing Postgres install hit it: initializeDatabase only
runs the createTable block on `if (!hasEventsTable)`. Once a
deployment has the events table from any prior run, the path is
skipped. So the bug only ever fires on a truly fresh Postgres
install — which is exactly MrGabri's scenario, and which our smoke
suite never exercises (it runs against a long-lived dev stack).

Fix:

- events createTable: drop the inline FK; column declared as a plain
  integer with an explainer comment.
- After both tables exist (post photos createTable): db.schema
  .alterTable('events').foreign('hero_photo_id').references...
  Wrapped in a try/catch that swallows "already exists" so re-runs
  on installs that previously got into a half-state don't fail boot.

Verified by docker compose down -v + up against the dev stack — no
FK error, all migrations apply, FK present in pg_constraint with
the expected definition.
2026-05-14 21:21:23 +02:00
Paul Nothaft 4225cd153f Merge pull request #491 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.5-beta.0
2026-05-14 21:02:58 +02:00
github-actions[bot] 1e4f762871 chore(beta): release 3.49.5-beta.0 2026-05-14 19:02:43 +00:00
Paul Nothaft d300426390 Merge pull request #490 from the-luap/fix/admin-users-sqlite-date-crash
fix(admin-users): normalise date fields to ISO across DB drivers (#485)
2026-05-14 21:02:13 +02:00
Paul Nothaft b6b58d0659 fix(admin-users): normalise date fields to ISO across DB drivers (#485)
Admin > Users page crashed with "TypeError: e.split is not a function"
on native installs (SQLite default). Reported by @blazmaric in #485
with a clean diagnosis: SQLite returns lastLogin / createdAt /
updatedAt as integer milliseconds since epoch, while Postgres
returns ISO strings via the standard JSON serialiser. The page used
parseISO() on the raw value and parseISO trips on numbers.

Fix at both layers — defence in depth:

- backend/src/routes/adminUsers.js: new toIso() helper applied in
  transformUser + transformInvitation. Coerces Date / number /
  numeric-string / null to a single ISO 8601 string contract before
  the response leaves the API. Protects every consumer (frontend
  AND external API tokens / n8n) regardless of which DB driver is
  underneath.
- frontend/src/services/userManagement.service.ts: same helper as
  defence-in-depth for stale backends mid-deploy and any cached
  pre-fix response shape. Also surfaced an existing
  transformInvitation gap — invitations endpoints were returning
  raw response.data.invitations without going through the
  transformer.

10 unit tests pin the toIso contract: all known driver shapes
(Date, number, numeric-string, ISO-string, null/undefined/empty)
plus the full transformer paths for transformUser and
transformInvitation.

Out of scope: same epoch-ms surface may exist on other admin pages
that were never tested against SQLite (events list, customers,
webhooks, api tokens, activity log). Worth a follow-up audit pass
to apply toIso() in every snake_case→camelCase transformer the
admin routes use, but the immediate Users-page crash is the only
reported one and shipping that fix unblocks @blazmaric.
2026-05-14 20:53:10 +02:00
Paul Nothaft a135544cab Merge pull request #489 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.4-beta.0
2026-05-14 20:50:23 +02:00
github-actions[bot] 57ea08e1ed chore(beta): release 3.49.4-beta.0 2026-05-14 18:49:31 +00:00
Paul Nothaft d39406b241 Merge pull request #488 from the-luap/fix/install-healthcheck-noise-and-stale-workers
fix(install): silence pg healthcheck noise + drop legacy workers container (#484)
2026-05-14 20:49:01 +02:00
Paul Nothaft d4155c4611 fix(install): drop racy migration step + add missing frontend container (#484)
Two follow-up fixes inside the same install-experience surface as
the previous commit:

1. **Removed `docker compose exec -T backend npm run migrate`** in
   both install_docker and update_docker_installation. The backend
   container's wait-for-db.sh already runs `npm run migrate:safe`
   on startup; the script was racing it with a separate (and
   non-safe) `npm run migrate`. That race is the most likely
   actual mechanism behind #484's "relation 'photos' does not
   exist" error on the second install attempt — partial schema
   visible to one of the two parallel migrators. Replaced with a
   bounded wait for the backend container to become healthy
   (Docker healthcheck reports green only after wait-for-db.sh
   finishes its migration pass).

2. **Added the missing frontend container** to the script-generated
   compose. The script previously generated a postgres + redis +
   backend stack with no frontend at all (backend on host port
   3001), while the documented production install
   (docker-compose.production.yml) ships postgres + redis +
   backend + frontend (nginx /api proxy on host port 3000). That
   shape divergence is half of issue B in #484 — script-installed
   admins had no frontend container and were left wondering where
   the UI lived. Aligning both compose files on the same shape
   eliminates the divergence; the frontend uses curl in its
   healthcheck (frontend/Dockerfile explicitly `apk add curl`)
   unlike the backend.

The remaining piece of issue B — picking ONE canonical install
path (build-from-source script vs. prebuilt-image production
compose) and deprecating the other — is a deployment-strategy
call that deserves its own design pass. Both paths now produce
architecturally-equivalent stacks.
2026-05-14 20:35:54 +02:00
Paul Nothaft 0b0b1bb2d5 fix(install): silence pg healthcheck noise + drop legacy workers container (#484)
Three install-experience bugs that compounded into MrGabri's "fresh
install fails" report:

1. **postgres healthcheck noise.** `pg_isready -U <user>` without
   -d defaults to probing a database whose name matches the user.
   Since DB_NAME defaults to picpeak_prod (not picpeak), every
   healthcheck interval logged
     FATAL: database "picpeak" does not exist
   into postgres logs even though the install was working
   correctly. Reporter saw the FATAL, assumed broken, restarted
   with DB_NAME=picpeak, hit a tainted-state migration error on
   the second try, filed a bug. Fixed in both
   docker-compose.production.yml and the inline compose generated
   by scripts/picpeak-setup.sh — pin -d to ${DB_NAME} so the
   probe hits the real database.

2. **backend container shows perpetually `unhealthy`.** Both
   compose files used `curl -f` for the backend healthcheck, but
   backend/Dockerfile only installs dumb-init + postgresql-client +
   ffmpeg — no curl. Switch to wget --no-verbose --tries=1 --spider
   to match what backend/Dockerfile's own HEALTHCHECK already
   does. Now docker ps, docker compose ps, and the backend image's
   built-in healthcheck all agree.

3. **stale separate `workers` container.** scripts/picpeak-setup.sh
   still generated a second container running `npm run workers`
   alongside the backend, but workers (fileWatcher,
   expirationChecker, emailQueueProcessor, backgroundProcessor,
   webhookWorker) have been started by server.js in-process for
   a while — see the comment at line ~895 of the same script for
   the systemd-side cleanup. The duplicate container caused two
   file watchers and two expiration checkers to compete for the
   same DB rows. Removed from the generated compose; install +
   upgrade paths now stop and rm any pre-existing picpeak-workers
   container.

Doesn't address issue B's bigger architecture mismatch (the script
generates a build-from-source compose with no frontend container,
while docker-compose.production.yml uses prebuilt images with a
separate frontend container). That deserves its own design pass
to pick a canonical install path and align — out of scope here.
2026-05-14 20:31:25 +02:00
Paul Nothaft 409ddf9c93 Merge pull request #487 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.3-beta.0
2026-05-14 20:24:30 +02:00
github-actions[bot] bc58520cd2 chore(beta): release 3.49.3-beta.0 2026-05-14 18:18:43 +00:00
Paul Nothaft d1034ce1c6 Merge pull request #486 from the-luap/fix/promo-banner-alignment
fix(promo-banner): center by default + admin alignment selector (#482)
2026-05-14 20:18:07 +02:00
Paul Nothaft a803491cf4 fix(promo-banner): center by default + admin alignment selector (#482)
The gallery promotional banner (#440) read as visually offset from
the gallery footer because:

  - Footer used `container text-center px-4` (full container width,
    centered text).
  - Promo block used `container py-4 sm:py-6` with an inner
    `max-w-3xl mx-auto` wrapper holding left-aligned text — a
    narrower column with left-aligned content sitting in the
    middle of the page.

Two issues compounded: the column was narrower than the footer AND
its text alignment differed. Reported by Rekoo-PS in #482 with a
screenshot showing the misalignment, with a request for an admin
alignment option.

Fix:

- Drop the inner max-w-3xl wrapper. Promo content now spans the
  same .container width as the footer, eliminating the
  narrower-column visual.
- Default text alignment changed from left → center to match the
  footer.
- New `branding_promo_alignment` setting ('left' | 'center' | 'right',
  default 'center'). Surfaced as a dropdown next to the existing
  Position dropdown on the BrandingPage. Live preview block on the
  BrandingPage mirrors the gallery render so admins see what
  guests will see.
- Also replaced the no-op `prose-sm` prose-modifier with a real
  `prose prose-sm` outer class so the existing `prose-a:text-accent`
  modifier actually takes effect (it didn't before — modifiers
  without an outer .prose are silently ignored by Tailwind
  Typography).

Migration 103 seeds the new setting at 'center' so existing
installs that have a promo banner today see the corrected
alignment immediately on next deploy.

i18n: en + de hand-translated; nl/pt/ru/fr machine-translated and
flagged for native review per project convention.
2026-05-14 20:10:14 +02:00
Paul Nothaft 3869e5c0dc Merge pull request #481 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.49.2-beta.0
2026-05-13 18:48:12 +02:00
github-actions[bot] 1cbb0c4cff chore(beta): release 3.49.2-beta.0 2026-05-13 16:46:19 +00:00
Paul Nothaft 6750f5d3b0 Merge pull request #480 from the-luap/fix/ci-trivy-platform-pin
fix(ci): pin TRIVY_PLATFORM per matrix arch (post-#477 follow-up)
2026-05-13 18:45:53 +02:00
Paul Nothaft c3256dc6bf fix(ci): pin TRIVY_PLATFORM per matrix arch (post-#477 follow-up)
PR #477 moved Trivy from the merge-* job into the per-arch build-*
matrix scanning by digest. The amd64 leg works; the arm64 leg
crashes with:

  remote error: no child with platform linux/amd64 in index
  ghcr.io/.../<image>@sha256:<digest>

Root cause: docker/build-push-action wraps every push in an OCI
index — the actual image manifest sits next to a SLSA provenance
attestation manifest as siblings under the digest. Trivy's remote
backend defaults to linux/amd64 when resolving an index, so:

  - amd64 leg → looks for amd64 child → finds the amd64 image → ok.
  - arm64 leg → looks for amd64 child → finds NO amd64 child
    (the only platform child is arm64) → fails.

Fix: set TRIVY_PLATFORM = ${{ matrix.platform }} on each leg's
Trivy step. Each scanner then asks for its own arch and finds it.
SLSA provenance attestation stays attached to the per-arch images
— a real win for supply-chain visibility we'd lose if we'd
disabled provenance instead.

amd64 was the only thing keeping CI partly green; this restores
full green across both legs without touching the build artifact
shape.
2026-05-13 18:41:49 +02:00
Paul Nothaft 067e460a4d Merge pull request #413 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.43.1
2026-05-07 20:09:30 +02:00
github-actions[bot] 3678193ae2 chore(main): release 3.43.1 2026-05-07 12:36:13 +00:00
Paul Nothaft 74eacbc78f Merge pull request #412 from the-luap/security/cve-backport-3.42.2
fix(security): backport 18 dependency CVE patches from beta (3.42.2 stable)
2026-05-07 14:35:47 +02:00
Paul Nothaft 37bf894412 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.

| 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 |

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 |

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.

* `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

* 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`.
2026-05-07 14:28:53 +02:00
Paul Nothaft 506b5c3dc4 Merge pull request #408 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.43.0
2026-05-07 13:00:20 +02:00
github-actions[bot] ab6db37326 chore(main): release 3.43.0 2026-05-07 10:59:36 +00:00
Paul Nothaft eb2ce290a7 Merge pull request #407 from the-luap/release/3.42.1-merge-from-beta
chore(release): promote beta → main as v3.42.1
2026-05-07 12:56:13 +02:00
Paul Nothaft 8a4c1a7c0a chore(release): promote beta → main as v3.42.1
Stable release promoting the entire `beta` channel to `main`. Brings
~300 commits of features, fixes, and infrastructure improvements that
have been baked on the beta channel since v2.6.5.

## Major themes since v2.6.5

* Multi-administrator support with RBAC (super admin / admin / editor)
* Async upload pipeline (background worker pool for sharp/ffmpeg/EXIF/
  watermark/webhooks; bytes-on-wire returns 202)
* Self-hosted webfonts (filesystem-driven scanner; replaces Google Fonts
  CDN; GDPR-compliant)
* 8-token CI palette + force color mode (full theming across admin and
  public site, with WCAG-safe contrast helpers)
* Native multi-arch Docker images (Apple Silicon + ARM64 Linux native)
* Native S3 storage backend (S3 + S3-compatible providers)
* Comprehensive video support (MP4/WebM/MOV upload, stream, play)
* Outbound webhooks for event/photo lifecycle (HMAC-signed)
* Gallery layout overhaul (decoupled header style, banner option,
  theme-aware skeletons, lazy-loaded folder tree picker)
* Multilingual email templates (EN/DE/NL/PT/RU translations table)
* Bulk operations (delete with password gate, archive)
* Photo dimensions backfill (true masonry layout)
* Customer client access (review area before guest share)
* Image security (devtools detection, watermarking, right-click,
  secure thumbnails)

## Notable bug fixes from beta

* `/auth/session` symmetry — three rounds of fixes (#350, #355, #363,
  #398) for the admin-login redirect-loop family
* Email template renderer: handle {{#if}} conditionals, fix CSS leak in
  plain-text fallback, gate publish-from-draft password placeholder,
  gate external_url in public response
* Caller/template variable drift across gallery_created,
  expiration_warning, archive_complete, gallery_expired
* Full-URL gallery_link in all email types (was path-only in 3 sites)
* ffmpeg/ffprobe via apk for Alpine compatibility (was glibc-bundled)
* Admin events search and counters not bounded to first 100 (#346)

## Conflict resolution notes

* `README.md` — kept main's leaner v2.6.5 rewrite (#281); added a
  Contributors section adapted from PR #393.
* `DEPLOYMENT_GUIDE.md` — beta version (more recent, includes External
  Media docs already backported to main).
* `CHANGELOG.md` — new 3.42.1 entry leads, beta's 3.x history follows,
  main's 2.x entries appended below a divider so the historical chain
  is preserved.
* `package.json` (backend + frontend) — beta's structure with version
  bumped from `3.42.1-beta.0` → `3.42.1`.
* `package-lock.json` (backend + frontend) — regenerated via
  `npm install --package-lock-only`.
* `.release-please-manifest.json` — bumped from `2.6.5` → `3.42.1` so
  the next release-please run on main starts from the correct base.

## Pre-flight checks

* Frontend `tsc --noEmit` — clean
* Frontend `vite build` — clean (~3.5s, 2.6 MB main chunk; existing
  warning about chunking, not new)
* Backend `npm test` — pre-existing failures in 6 integration suites
  (DB-fixture-dependent, not regressions)
* Frontend `vitest` — pre-existing failures in
  ThemeCustomizerEnhanced.test.tsx (missing QueryClientProvider after
  PR #390 added useQuery; not a regression of this merge)

The pre-existing test failures are tracked as separate follow-ups and
do not block this release promotion.
2026-05-07 12:47:45 +02:00
Paul Nothaft 4d3836fb2e Merge pull request #282 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.6.5
2026-04-08 13:18:17 +02:00
github-actions[bot] 75499992eb chore(main): release 2.6.5 2026-04-08 11:15:26 +00:00
Paul Nothaft 62643f241b Merge pull request #281 from the-luap/docs/readme-rewrite-main
docs: rewrite README — shorter, cleaner
2026-04-08 13:15:07 +02:00
Paul Nothaft 64f606152f docs: rewrite README — shorter, cleaner, less AI-sounding
Rewrote from 350 lines to ~130 lines. Removed emoji-heavy headings,
marketing fluff, redundant sections, and the AI disclosure. Collapsed
screenshots into details tags. Kept all essential info: demo, features,
quick start, comparison, tech stack, docs links.
2026-04-08 13:14:57 +02:00
Paul Nothaft e2a698e892 Merge pull request #277 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.6.4
2026-04-08 09:39:15 +02:00
github-actions[bot] d1d71dba25 chore(main): release 2.6.4 2026-04-08 07:14:32 +00:00
Paul Nothaft bb81fa5f4b Merge pull request #276 from the-luap/fix/main-lockfile-sync
fix: sync backend package-lock.json for security deps
2026-04-08 09:14:16 +02:00
Paul Nothaft 03e19893b3 fix: sync backend package-lock.json with security dep updates
The lock file was not committed with PR #275, causing npm ci to fail
in Docker builds. Regenerate to match the updated package.json overrides.
2026-04-08 09:14:06 +02:00
Paul Nothaft 279314e4b7 Merge pull request #275 from the-luap/security/fix-dep-vulnerabilities-main
security: fix 20 dependency vulnerabilities (backport)
2026-04-08 09:05:56 +02:00
Paul Nothaft 730912a3f4 security: fix 20 dependency vulnerabilities (backport to main)
Same fixes as beta PR #274. Updates handlebars, nodemailer, tar,
fast-xml-parser, brace-expansion, path-to-regexp, and lodash to
address 20 GitHub code scanning alerts.
2026-04-08 09:05:48 +02:00
Paul Nothaft ff9fb64e75 Merge pull request #273 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.6.3
2026-04-07 20:40:47 +02:00
github-actions[bot] 9cbbe74051 chore(main): release 2.6.3 2026-04-07 18:40:34 +00:00
Paul Nothaft 2e1c71c1ab Merge pull request #272 from the-luap/docs/external-media-library-270
docs: add External Media Library section to deployment guide (#270)
2026-04-07 20:40:11 +02:00
Paul Nothaft f6ca713a6e docs: add External Media Library section to deployment guide (#270)
Add the missing "External Media Library" chapter to DEPLOYMENT_GUIDE.md
that was referenced in the TOC but never written. Covers configuration,
Docker volume mounting, folder structure, usage workflow, limitations,
and troubleshooting.

Closes #270
2026-04-07 19:52:02 +02:00
Paul Nothaft 197cd8e1e0 Merge pull request #268 from the-luap/security/pin-axios-main
security: pin axios to 1.14.0 — supply chain attack prevention
2026-04-05 18:41:54 +02:00
Paul Nothaft 681b440381 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 attributed to North Korean threat actor. Pin to
exact 1.14.0 (latest safe release) to prevent resolution to compromised
versions. See https://github.com/axios/axios/issues/10604
2026-04-05 18:41:45 +02:00
Paul Nothaft 3daeac9e53 Merge pull request #246 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.6.2
2026-03-16 22:37:56 +01:00
github-actions[bot] 7febba2d9c chore(main): release 2.6.2 2026-03-16 21:37:35 +00:00
Paul Nothaft 0a3a53763c Merge pull request #245 from the-luap/fix/security-session-invalidation-main
fix(security): token invalidation on password change, session timeout enforcement
2026-03-16 22:37:16 +01:00
Paul Nothaft 85a60a2dc7 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
2026-03-16 22:36:52 +01:00
Paul Nothaft e74e73a3a0 Merge pull request #231 from the-luap/i18n/ru-missing-keys
i18n: add missing Russian translations for thumbnails and photo dimensions
2026-03-15 20:01:15 +01:00
106 changed files with 9925 additions and 2789 deletions
+10 -6
View File
@@ -68,6 +68,16 @@ EMAIL_FROM=noreply@yourdomain.com
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com
# Static HTML title + description used for social link previews when the
# fetcher doesn't trigger the per-event OG endpoint — most notably the
# WhatsApp Business API and various 3rd-party preview-service caches
# (#521). Set these to your brand so link previews aren't generic.
# Substituted into index.html at frontend-container start, so changes
# take effect on the next `docker compose up -d frontend` — no rebuild
# required.
BRAND_TITLE=PicPeak
BRAND_DESCRIPTION=Photo gallery shared with PicPeak.
# API URL for email assets (logos, images in notification emails)
# This must be the publicly accessible URL where email recipients can load images.
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
@@ -97,12 +107,6 @@ UPDATE_CHECK_ENABLED=true
# Timezone
TZ=UTC
# Runtime user mapping for Docker (optional)
# Set these to your host user's UID/GID to avoid permission issues on bind mounts.
# Run `id -u` and `id -g` on host to get values. Defaults to 1001.
PUID=1001
PGID=1001
# Analytics (Optional - Umami)
VITE_UMAMI_URL=
VITE_UMAMI_WEBSITE_ID=
+17
View File
@@ -173,6 +173,17 @@ jobs:
- name: Run Trivy vulnerability scanner (per-arch, by digest)
if: steps.push-decision.outputs.push == 'true'
uses: aquasecurity/trivy-action@v0.36.0
env:
# docker/build-push-action wraps every push in an OCI index
# (carries the SLSA provenance attestation alongside the
# actual image). Trivy's remote backend defaults to
# linux/amd64 regardless of host arch when resolving an
# index, which makes the arm64 leg crash with "no child
# with platform linux/amd64". Telling Trivy which child to
# scan keeps the provenance attestation intact and fixes
# the resolver crash. Pin to matrix.platform so each leg
# scans its own arch.
TRIVY_PLATFORM: ${{ matrix.platform }}
with:
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'sarif'
@@ -379,6 +390,12 @@ jobs:
- name: Run Trivy vulnerability scanner (per-arch, by digest)
if: steps.push-decision.outputs.push == 'true'
uses: aquasecurity/trivy-action@v0.36.0
env:
# See build-backend for the rationale — pin Trivy's platform
# to the matrix arch so its remote-index resolver picks the
# right child instead of defaulting to linux/amd64 and
# crashing on the arm64 leg.
TRIVY_PLATFORM: ${{ matrix.platform }}
with:
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: 'sarif'
+227
View File
@@ -0,0 +1,227 @@
name: Fresh-install smoke
# Verifies that a clean Postgres install boots cleanly under the same
# conditions a new user hits on their first `docker compose up -d`. The
# specific scenarios this guards against — see #484 for the original
# reproduction:
#
# 1. Bind-mounted host directories owned by a UID other than 1001
# (the container's nodejs user). The entrypoint must self-chown
# and drop privileges via su-exec.
# 2. Cold-start Postgres with no prior schema (the FK-order bug fixed
# in #494, the index/created_at error fixed in #511, and any
# future migration-order issue that only surfaces on an empty DB).
#
# Triggers only on changes that touch the install path so unrelated PRs
# don't pay the build cost.
on:
push:
branches: [main, beta]
paths:
- 'backend/Dockerfile'
- 'backend/wait-for-db.sh'
- 'backend/migrations/**'
- 'backend/package*.json'
- 'docker-compose.production.yml'
- '.github/workflows/install-smoke.yml'
pull_request:
branches: [main, beta]
paths:
- 'backend/Dockerfile'
- 'backend/wait-for-db.sh'
- 'backend/migrations/**'
- 'backend/package*.json'
- 'docker-compose.production.yml'
- '.github/workflows/install-smoke.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
fresh-install:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# Build for the runner's arch only — we just need a runnable image.
# The full multi-arch build is the docker-build workflow's job.
- name: Build backend image
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
load: true
tags: picpeak-backend:smoke
cache-from: type=gha,scope=install-smoke
cache-to: type=gha,mode=max,scope=install-smoke
- name: Create Docker network
run: docker network create picpeak-smoke
# Mount as UID 1000 (the typical GitHub Actions runner user, and a
# common mismatch case on Linux hosts). The entrypoint must chown
# this to 1001 itself — that's the regression we're guarding.
- name: Prepare host bind-mount dirs owned by UID 1000
run: |
mkdir -p smoke-mounts/storage smoke-mounts/data smoke-mounts/logs
chmod 755 smoke-mounts smoke-mounts/*
ls -ld smoke-mounts/*
- name: Start Postgres
run: |
docker run -d --name picpeak-smoke-pg --network picpeak-smoke \
-e POSTGRES_USER=picpeak \
-e POSTGRES_PASSWORD=smokepass \
-e POSTGRES_DB=picpeak_prod \
--health-cmd="pg_isready -U picpeak -d picpeak_prod" \
--health-interval=2s --health-timeout=2s --health-retries=30 \
postgres:15-alpine
- name: Wait for Postgres healthy
run: |
for i in $(seq 1 60); do
status=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-pg 2>/dev/null || echo starting)
if [ "$status" = "healthy" ]; then
echo "postgres healthy after ${i}s"
exit 0
fi
sleep 1
done
echo "postgres did not become healthy in 60s"
docker logs picpeak-smoke-pg
exit 1
- name: Start backend with mismatched-UID bind mounts (fresh install)
run: |
docker run -d --name picpeak-smoke-bk --network picpeak-smoke \
-e NODE_ENV=production \
-e JWT_SECRET=smoketestsecretvalueof32characters \
-e DB_HOST=picpeak-smoke-pg \
-e DB_USER=picpeak \
-e DB_PASSWORD=smokepass \
-e DB_NAME=picpeak_prod \
-e ADMIN_EMAIL=admin@smoke.local \
-e ADMIN_PASSWORD=smokeAdminPass12345 \
-e STORAGE_PATH=/app/storage \
-v "$PWD/smoke-mounts/storage:/app/storage" \
-v "$PWD/smoke-mounts/data:/app/data" \
-v "$PWD/smoke-mounts/logs:/app/logs" \
picpeak-backend:smoke
- name: Wait for backend healthy
run: |
for i in $(seq 1 120); do
status=$(docker inspect -f '{{.State.Status}}' picpeak-smoke-bk 2>/dev/null || echo missing)
health=$(docker inspect -f '{{.State.Health.Status}}' picpeak-smoke-bk 2>/dev/null || echo none)
if [ "$status" = "exited" ]; then
echo "FAIL: backend exited during cold-start (restart loop scenario)"
docker logs picpeak-smoke-bk
echo "--- error.log ---"
cat smoke-mounts/logs/error.log 2>/dev/null || echo "(no error.log)"
exit 1
fi
if [ "$health" = "healthy" ]; then
echo "backend healthy after ${i}s"
exit 0
fi
sleep 1
done
echo "FAIL: backend did not become healthy in 120s"
docker ps -a
docker logs picpeak-smoke-bk
exit 1
- name: Verify chown happened (container view)
run: |
# All three dirs should now be owned by nodejs (UID 1001).
# If the entrypoint's self-chown branch didn't fire, they'd
# still be owned by the runner UID and node would have hit
# EACCES creating storage subdirs.
for d in /app/storage /app/data /app/logs; do
owner_uid=$(docker exec picpeak-smoke-bk stat -c '%u' "$d")
if [ "$owner_uid" != "1001" ]; then
echo "FAIL: $d is owned by UID $owner_uid (expected 1001)"
exit 1
fi
echo "ok: $d owned by UID $owner_uid"
done
- name: Verify app is actually serving
run: |
# /health is what docker's HEALTHCHECK polls, but hit it
# directly to confirm the response shape matches what the
# frontend + reverse proxy expect.
body=$(docker exec picpeak-smoke-bk wget -qO- http://localhost:3000/health)
echo "/health => $body"
echo "$body" | grep -q '"status":"ok"' || {
echo "FAIL: /health did not return status:ok"
exit 1
}
- name: Verify node runs as nodejs (not root)
run: |
# dumb-init runs as root (PID 1), node must be running as
# nodejs (UID 1001) — if su-exec drop didn't happen the app
# would be running as root which is the security regression
# we're guarding against. Alpine ships BusyBox ps, which
# doesn't support `-p PID` or pgrep, so list + awk instead.
user=$(docker exec picpeak-smoke-bk ps -o user,comm | awk '$2=="node" {print $1; exit}')
if [ "$user" != "nodejs" ]; then
echo "FAIL: node running as '$user' (expected nodejs)"
docker exec picpeak-smoke-bk ps -o pid,user,comm
exit 1
fi
echo "ok: node running as $user"
- name: Verify no restart loop
run: |
restart_count=$(docker inspect -f '{{.RestartCount}}' picpeak-smoke-bk)
if [ "$restart_count" -gt 0 ]; then
echo "FAIL: container restarted $restart_count time(s) — install loop bug returning"
docker logs picpeak-smoke-bk
exit 1
fi
echo "ok: 0 restarts"
# Restart with `--user 5005:5005` (no root, can't chown) against
# bind mounts owned by 1000 — entrypoint must fail loud with the
# actionable preflight error, not silently restart-loop.
- name: Verify preflight fails loud on unwritable mounts
run: |
docker rm -f picpeak-smoke-bk2 2>/dev/null || true
set +e
out=$(docker run --rm --user 5005:5005 --network picpeak-smoke \
-e NODE_ENV=production -e JWT_SECRET=x \
-e DB_HOST=picpeak-smoke-pg -e DB_USER=picpeak \
-e DB_PASSWORD=smokepass -e DB_NAME=picpeak_prod \
-e STORAGE_PATH=/app/storage \
-v "$PWD/smoke-mounts/storage:/app/storage" \
-v "$PWD/smoke-mounts/data:/app/data" \
-v "$PWD/smoke-mounts/logs:/app/logs" \
picpeak-backend:smoke 2>&1)
rc=$?
set -e
echo "$out"
if [ $rc -eq 0 ]; then
echo "FAIL: preflight should have exited non-zero"
exit 1
fi
echo "$out" | grep -q "is not writable by UID 5005" || {
echo "FAIL: preflight error message missing or wrong"
exit 1
}
echo "ok: preflight failed loud with actionable error"
- name: Cleanup
if: always()
run: |
docker rm -f picpeak-smoke-bk picpeak-smoke-bk2 picpeak-smoke-pg 2>/dev/null || true
docker network rm picpeak-smoke 2>/dev/null || true
+189
View File
@@ -0,0 +1,189 @@
name: Schema drift (#530)
# Verifies that `migrate:safe` can recover a DB that's been seeded only
# by `initializeDatabase()` — the recovery scenario where the migrations
# tracking table is empty but the schema already has the modern bootstrap.
#
# This is NOT how production reaches its state on normal installs or
# upgrades. The scenario only fires when:
# - A backup was restored that captured tables but not the migrations
# table (manifest divergence),
# - Someone manually invoked initializeDatabase() outside the migration
# runner (recovery / debugging),
# - The DB was moved between systems and the migrations table was not
# copied along.
#
# When `detectExistingSchema()` sees the modern-bootstrap fingerprint
# (photo_categories + cms_pages tables) but an empty migrations table,
# it treats it as an "existing deployment" — which runs the legacy
# chain first. Legacy/008 renames email_templates.subject → subject_en,
# but core/029 (which runs later in this chain) inserts email templates
# referencing the pre-rename column name. The chain dies with a
# "column subject does not exist" error.
#
# Fix (in the same PR as this workflow): when the modern-bootstrap
# fingerprint is detected, mark all legacy migrations as applied so the
# chain matches what a fresh install runs — only core/*, in order.
#
# This workflow boots the failing scenario from scratch on every PR
# that touches the migrations or db.js, so any future migration with
# the same shape is caught before merge.
on:
push:
branches: [main, beta]
paths:
- 'backend/migrations/**'
- 'backend/src/database/db.js'
- 'backend/knexfile.js'
- '.github/workflows/schema-drift.yml'
pull_request:
branches: [main, beta]
paths:
- 'backend/migrations/**'
- 'backend/src/database/db.js'
- 'backend/knexfile.js'
- '.github/workflows/schema-drift.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
upgrade-from-bootstrap:
runs-on: ubuntu-latest
timeout-minutes: 10
services:
postgres:
image: postgres:15-alpine
env:
POSTGRES_USER: picpeak
POSTGRES_PASSWORD: testpass
POSTGRES_DB: picpeak_drift
options: >-
--health-cmd "pg_isready -U picpeak -d picpeak_drift"
--health-interval 2s
--health-timeout 2s
--health-retries 30
ports:
- 5432:5432
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: backend/package-lock.json
- name: Install backend deps
working-directory: ./backend
run: npm ci
# Step 1: simulate the recovery state — DB has the modern bootstrap
# (post-initializeDatabase) but no migrations recorded. Calling
# initializeDatabase() directly outside the migration runner is the
# one-line repro for backup-restore-lost-migrations and manual-
# invocation paths.
- name: Seed DB with initializeDatabase() only
working-directory: ./backend
env:
NODE_ENV: production
DATABASE_CLIENT: pg
DB_HOST: localhost
DB_PORT: 5432
DB_USER: picpeak
DB_PASSWORD: testpass
DB_NAME: picpeak_drift
run: |
node -e "require('./src/database/db').initializeDatabase().then(() => { console.log('bootstrap ok'); process.exit(0); }).catch(e => { console.error('bootstrap FAILED:', e.message); process.exit(1); })"
# Sanity-check the recovery shape before migrate:safe runs. If
# initializeDatabase() ever stops producing photo_categories +
# cms_pages, the fingerprint check would silently no-op and this
# workflow would lose its teeth — assert the precondition.
- name: Assert recovery-state fingerprint
env:
PGPASSWORD: testpass
run: |
installed=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public' AND tablename IN ('photo_categories', 'cms_pages')")
if [ "$installed" != "2" ]; then
echo "FAIL: expected photo_categories + cms_pages from initializeDatabase(); got $installed."
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
exit 1
fi
# initializeDatabase() doesn't create the `migrations` tracking
# table — that's the migrate:safe runner's job. So in the recovery
# scenario, the table either (a) doesn't exist yet or (b) exists
# but is empty (e.g. someone created it but didn't populate it).
# Both are valid recovery states; check via to_regclass first so
# we don't parse a SELECT against a nonexistent table.
has_migrations_table=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT to_regclass('public.migrations')::text")
if [ -z "$has_migrations_table" ]; then
migrations_count=0
else
migrations_count=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM migrations")
fi
if [ "$migrations_count" != "0" ]; then
echo "FAIL: migrations table should be empty for the recovery scenario; has $migrations_count rows."
exit 1
fi
echo "ok: recovery state confirmed (bootstrap tables present, migrations table empty or absent)."
# Step 2: run migrate:safe — the test. Before #530's fix in
# detectExistingSchema, this died at core/029 with a "column
# subject does not exist" error. After the fix, it should complete
# cleanly with every migration either applied or marked.
- name: Run migrate:safe against the recovery state
working-directory: ./backend
env:
NODE_ENV: production
DATABASE_CLIENT: pg
DB_HOST: localhost
DB_PORT: 5432
DB_USER: picpeak
DB_PASSWORD: testpass
DB_NAME: picpeak_drift
run: npm run migrate:safe
# Step 3: schema-shape assertion. A fresh install through migrate:
# safe produces 48 tables; the recovery scenario should converge
# to the same number. Off-by-one is fine but a 10+ table delta
# means a migration silently bailed in the recovery path.
- name: Assert final schema matches fresh-install shape
env:
PGPASSWORD: testpass
run: |
tables=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM pg_tables WHERE schemaname='public'")
echo "Final table count: $tables"
# Allow a small drift window — exact count creeps over time as
# new migrations land; tight pin would force a workflow edit
# on every schema PR. 40+ is a healthy floor that catches the
# original bug (which left 17 tables) while staying robust to
# forward changes.
if [ "$tables" -lt 40 ]; then
echo "FAIL: too few tables ($tables) — migrate:safe likely bailed mid-chain."
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"
exit 1
fi
echo "ok: schema converged to a fresh-install-equivalent shape."
# Step 4: verify the legacy migrations were all marked applied
# (rather than silently bailing inside the chain). The fix in
# detectExistingSchema marks legacy/* when the modern bootstrap
# is detected — confirm the markings actually landed.
- name: Assert legacy migrations marked applied
env:
PGPASSWORD: testpass
run: |
legacy_count=$(psql -h localhost -U picpeak -d picpeak_drift -tAc "SELECT count(*) FROM migrations WHERE filename LIKE '008_%' OR filename LIKE '009_%' OR filename LIKE '013_%' OR filename LIKE '019_%' OR filename LIKE '020_%' OR filename LIKE '026_%' OR filename LIKE '028_%'")
if [ "$legacy_count" -lt 7 ]; then
echo "FAIL: legacy migrations not marked applied ($legacy_count of 7 expected)."
psql -h localhost -U picpeak -d picpeak_drift -c "SELECT filename FROM migrations WHERE filename LIKE '0%' ORDER BY filename"
exit 1
fi
echo "ok: legacy migrations marked applied by detectExistingSchema."
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.49.1-beta.0"
".": "3.55.0-beta.0"
}
+1 -3
View File
@@ -1,3 +1 @@
{
".": "2.6.1"
}
{".":"3.44.0"}
+475 -210
View File
@@ -5,266 +5,493 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.49.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.49.0-beta.0...v3.49.1-beta.0) (2026-05-13)
### Bug Fixes
* **ci:** scan multi-arch images per-arch by digest, pin trivy-action ([#476](https://github.com/the-luap/picpeak/issues/476)) ([1144e9d](https://github.com/the-luap/picpeak/commit/1144e9d1625eb69d8e2a53b1a3aa1b515798fc80))
## [3.49.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.48.1-beta.0...v3.49.0-beta.0) (2026-05-13)
### Features
* **og:** per-event opt-in to use hero photo as social-share preview ([#474](https://github.com/the-luap/picpeak/issues/474)) ([d856340](https://github.com/the-luap/picpeak/commit/d856340f0d230ad26539ba1088f739f03aaafc13))
* **og:** per-event opt-in to use hero photo as social-share preview ([#474](https://github.com/the-luap/picpeak/issues/474)) ([0bc7e2a](https://github.com/the-luap/picpeak/commit/0bc7e2af171d1a4c6e91ba541d293b90c17c111b))
## [3.48.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.48.0-beta.0...v3.48.1-beta.0) (2026-05-12)
### Bug Fixes
* **customer-routes:** Cache-Control: no-store on customer endpoints ([#470](https://github.com/the-luap/picpeak/issues/470)) ([3122dd0](https://github.com/the-luap/picpeak/commit/3122dd08a8bc08deb937236aaa3f750119a979b9))
## [3.48.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.47.2-beta.0...v3.48.0-beta.0) (2026-05-12)
## [3.44.0](https://github.com/the-luap/picpeak/compare/v3.43.1...v3.44.0) (2026-05-27)
### Features
* **api/v1:** accept category_id on POST /events/:id/photos ([2d5a2ad](https://github.com/the-luap/picpeak/commit/2d5a2ad78a5f6c101315214399a9c158ff0549da))
* **api/v1:** accept category_id on POST /events/:id/photos ([6901e26](https://github.com/the-luap/picpeak/commit/6901e2661ed74e69f19c52ce046ee911b818d463))
* **branding:** Customer dashboard header toggles in Branding page ([b252cb6](https://github.com/the-luap/picpeak/commit/b252cb67eb3645224a279fbbe9c14871438473f8))
* **branding:** toggle login-page logo frame + size ([75e41eb](https://github.com/the-luap/picpeak/commit/75e41eba036d07e6abaab301db42d16e77dd01be))
* **clients:** scaffold top-level Clients section with sub-nav around Accounts ([9091ed4](https://github.com/the-luap/picpeak/commit/9091ed4012a85400f216ebfb40d5720c2c86a826))
* customer accounts ([#354](https://github.com/the-luap/picpeak/issues/354)) — recurring logins, profile, password reset, branded customer surface ([fe52953](https://github.com/the-luap/picpeak/commit/fe5295373b0bfeec1e81086206ffab7ce1b91094))
* **customers:** "Manage galleries" dialog on customer detail page ([6d1af7a](https://github.com/the-luap/picpeak/commit/6d1af7a0113b6e3de44ab9bf2645591f4d6d68b4))
* **customers:** "Manage galleries" dialog with immediate access revocation + section reorder + portal-flag revert ([9be9296](https://github.com/the-luap/picpeak/commit/9be9296eb58d7733d0b5f8ce6eb48e9832f65e7c))
* **customers:** customer portal ([#354](https://github.com/the-luap/picpeak/issues/354)) on top of feature-flags reorg ([087ef45](https://github.com/the-luap/picpeak/commit/087ef45942a8a51d09af2cd8ec85aca330f6cf7f))
* **customers:** email customer when admin adds new gallery access ([c02c947](https://github.com/the-luap/picpeak/commit/c02c947463011de80d9af3e947fc6d1caadc3313))
* **customers:** replace-assignments endpoint for a single customer ([5377b88](https://github.com/the-luap/picpeak/commit/5377b88e0e0c27ecde18c2a366fb7d06f279f2ec))
* **gallery:** revoke customer-minted JWTs when assignment is removed ([55a5846](https://github.com/the-luap/picpeak/commit/55a5846f6f802a1bc1910bb046325fe272a1b584))
### Bug Fixes
* **customer:** don't log customer out on transient session-refresh errors ([9e418c7](https://github.com/the-luap/picpeak/commit/9e418c759ce508adf6025e0740468d8229938ffe))
### Reverts
* **customer-portal:** make the global flag UI-only, drop the kill-switch middleware ([3f44193](https://github.com/the-luap/picpeak/commit/3f4419356a4f30509052a6d00b71485af2c17f85))
## [3.47.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.47.1-beta.0...v3.47.2-beta.0) (2026-05-11)
### Bug Fixes
* **activity-log:** smart feature_flags_updated rendering + 33 missing activity types ([4703fd5](https://github.com/the-luap/picpeak/commit/4703fd574f57bfab327ffefec5986bb08b240c95))
## [3.47.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.47.0-beta.0...v3.47.1-beta.0) (2026-05-11)
### Bug Fixes
* **features:** customer-portal card uses 'Clients' to match sidebar wording ([441cc41](https://github.com/the-luap/picpeak/commit/441cc419377055a5872f71afb19036cc5c932b58))
* **features:** customer-portal card uses 'Clients' to match sidebar wording ([dec2f5d](https://github.com/the-luap/picpeak/commit/dec2f5d3d2224b0d66f02bb9a562b4ddeaac77df))
## [3.47.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.46.3-beta.0...v3.47.0-beta.0) (2026-05-11)
### Features
* **downloads:** preserve original camera filenames on download (opt-in) ([#493](https://github.com/the-luap/picpeak/issues/493)) ([826e43e](https://github.com/the-luap/picpeak/commit/826e43ebac940782be234630c50bbd54a3250f98))
* **downloads:** preserve original camera filenames on download (opt-in) ([#493](https://github.com/the-luap/picpeak/issues/493)) ([7eeef2b](https://github.com/the-luap/picpeak/commit/7eeef2ba98a71a8b948cabf7f1c65eb3eb271d22))
* **email-templates:** categorise + link to feature flags ([84c06af](https://github.com/the-luap/picpeak/commit/84c06affb73687529e35dbbac15801deb41dc4f2))
* **email-templates:** categorise + sub-categorise + link to feature flags ([2cae3fe](https://github.com/the-luap/picpeak/commit/2cae3fe47deb667af5991ae1a90e3b5698693119))
* **email-templates:** group Templates UI by category + Feature off chip ([5ec26fc](https://github.com/the-luap/picpeak/commit/5ec26fc9981028163cab122e229c83cdbbf35828))
* **email-templates:** group Templates UI by category with core sub-sections ([53eecb6](https://github.com/the-luap/picpeak/commit/53eecb6f83ff75f1f3c75d68cee5e46d114ac8d1))
* **email-templates:** seed missing locale translations + post-075 templates ([e3150e4](https://github.com/the-luap/picpeak/commit/e3150e42130cacf19dc7beadb8c86c76c0fff340))
* **email-templates:** seed missing nl/pt/ru/fr translations ([358f7ee](https://github.com/the-luap/picpeak/commit/358f7ee99e2941ad179d5859178b35a80886542f))
### Bug Fixes
* **email-templates:** backfill subcategory + customer password reset translations ([2343a16](https://github.com/the-luap/picpeak/commit/2343a162df070cd5bd6abdc331bc5ca4aa283132))
## [3.46.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.46.2-beta.0...v3.46.3-beta.0) (2026-05-11)
### Bug Fixes
* **branding:** socials + promo round-trip from DB to form ([#460](https://github.com/the-luap/picpeak/issues/460)) ([bd2288e](https://github.com/the-luap/picpeak/commit/bd2288e6a01786cec0649b3326189e9737db359e))
* **branding:** socials + promo round-trip from DB to form ([#460](https://github.com/the-luap/picpeak/issues/460)) ([ae64a6a](https://github.com/the-luap/picpeak/commit/ae64a6acbc119f78394e65cee7bf49910e1c7013))
## [3.46.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.46.1-beta.0...v3.46.2-beta.0) (2026-05-11)
### Bug Fixes
* **customer-portal:** post-merge fixes for event save, theme fonts, and customer→gallery handoff ([9776d8a](https://github.com/the-luap/picpeak/commit/9776d8a6fcccb5e19e7c652b7e47557213d85731))
* **events:** CustomerAccountPicker hooks order crashed /admin/events/new ([2a7ae07](https://github.com/the-luap/picpeak/commit/2a7ae0702dfc56e69bf845ded19888c29519414a))
## [3.46.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.46.0-beta.0...v3.46.1-beta.0) (2026-05-11)
### Bug Fixes
* **import:** capture photo dimensions in fileWatcher + s3AutoImporter ([#447](https://github.com/the-luap/picpeak/issues/447)) ([5b14854](https://github.com/the-luap/picpeak/commit/5b148542e6f2396ce47e3b6c301f186f9af9adec))
## [3.46.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.45.1-beta.0...v3.46.0-beta.0) (2026-05-11)
### Features
* **branding:** Customer dashboard header toggles in Branding page ([b252cb6](https://github.com/the-luap/picpeak/commit/b252cb67eb3645224a279fbbe9c14871438473f8))
* customer accounts ([#354](https://github.com/the-luap/picpeak/issues/354)) — recurring logins, profile, password reset, branded customer surface ([fe52953](https://github.com/the-luap/picpeak/commit/fe5295373b0bfeec1e81086206ffab7ce1b91094))
* **customers:** customer portal ([#354](https://github.com/the-luap/picpeak/issues/354)) on top of feature-flags reorg ([087ef45](https://github.com/the-luap/picpeak/commit/087ef45942a8a51d09af2cd8ec85aca330f6cf7f))
### Bug Fixes
* **auth:** restore COOKIE_SECURE='auto' default for production ([adfa29e](https://github.com/the-luap/picpeak/commit/adfa29e91eeea52aa672e38269c389a5178d9e5a))
* **customer:** unwrap /customer/* from RequireFeature gate ([da08a58](https://github.com/the-luap/picpeak/commit/da08a5828ab855365a2a2a6f4854b09eb409907a))
* **server:** drop missing requireCustomerPortal middleware import ([4fa7225](https://github.com/the-luap/picpeak/commit/4fa72257329942a6b598fa90c83c6bca7586fe33))
* **server:** mount /api/admin/feature-flags route ([f048011](https://github.com/the-luap/picpeak/commit/f048011324bfa4cee8f89b0131b68dd520446ca2))
## [3.45.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.45.0-beta.0...v3.45.1-beta.0) (2026-05-10)
### Bug Fixes
* **create-event:** branding-default theme survives eventTypes refetch ([d62c529](https://github.com/the-luap/picpeak/commit/d62c529b0278a9ac790b22f46f49004df71112ea))
* **create-event:** branding-default theme survives eventTypes refetch ([#323](https://github.com/the-luap/picpeak/issues/323)-B) ([37d487d](https://github.com/the-luap/picpeak/commit/37d487db86fe9fc11facff6d2c0bb9a24e6f6277))
## [3.45.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.44.2-beta.0...v3.45.0-beta.0) (2026-05-10)
### Features
* **events:** default Guest Feedback ON via admin setting ([#520](https://github.com/the-luap/picpeak/issues/520)) ([3465b55](https://github.com/the-luap/picpeak/commit/3465b55abc98e52cf58ba46b811f4ec115d53012))
* **footer:** hideable legal links + socials + promo banner ([#441](https://github.com/the-luap/picpeak/issues/441) + [#440](https://github.com/the-luap/picpeak/issues/440)) ([f3505c2](https://github.com/the-luap/picpeak/commit/f3505c2631cc5b4ae13a0f0d593a1ddd95fefdcb))
## [3.44.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.44.1-beta.0...v3.44.2-beta.0) (2026-05-10)
### Bug Fixes
* **events:** clamp page state when totalPages drops below current page ([#442](https://github.com/the-luap/picpeak/issues/442)) ([b4e30a4](https://github.com/the-luap/picpeak/commit/b4e30a4293c77e6dc34e955741ee113b9d530718))
* **events:** clamp page state when totalPages drops below current page ([#442](https://github.com/the-luap/picpeak/issues/442)) ([9c4a96f](https://github.com/the-luap/picpeak/commit/9c4a96fe977b7a0907f5dea99491385195b76184))
## [3.44.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.44.0-beta.0...v3.44.1-beta.0) (2026-05-10)
### Bug Fixes
* **events:** admins can clear expiration on edit even when 'Require expiration' is ON ([#426](https://github.com/the-luap/picpeak/issues/426)) ([3fd8af3](https://github.com/the-luap/picpeak/commit/3fd8af3d56b54f81cc20b75109cc212d23fc84c1))
* **events:** admins can clear expiration on edit even when "Require expiration" is ON ([#426](https://github.com/the-luap/picpeak/issues/426)) ([e544561](https://github.com/the-luap/picpeak/commit/e54456135cc8605fad949a955261cecc3f986355))
## [3.44.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.43.3-beta.0...v3.44.0-beta.0) (2026-05-10)
### Features
* **settings:** Features tab + sidebar reorg with feature-flag gating ([c3798e1](https://github.com/the-luap/picpeak/commit/c3798e19c8f928eac0ca1d7694d1ecfd78a1e437))
* **settings:** Features tab + sidebar reorg with feature-flag gating ([15e3336](https://github.com/the-luap/picpeak/commit/15e333681fe4ce94afa8e5b477b339b179d0195a))
## [3.43.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.43.2-beta.0...v3.43.3-beta.0) (2026-05-09)
### Bug Fixes
* **gallery:** serve thumbnails / photos / hero via storage abstraction ([#432](https://github.com/the-luap/picpeak/issues/432)) ([d3007b0](https://github.com/the-luap/picpeak/commit/d3007b0dd29d37a46ce26e8b4eb15908e0f8e3d2))
## [3.43.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.43.1-beta.0...v3.43.2-beta.0) (2026-05-09)
### Documentation
* **contributing:** update branch reference from main to beta ([ed37caf](https://github.com/the-luap/picpeak/commit/ed37caf3d898d9b2db985e6c6ff203457fd4aa38))
## [3.43.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.43.0-beta.0...v3.43.1-beta.0) (2026-05-09)
### Bug Fixes
* **event:** correct updating client access ([d00f6fa](https://github.com/the-luap/picpeak/commit/d00f6fa7de59bdbd30efe9ce824e795cf05616f4))
* **event:** ensure client share token is generated only when necessary ([916580a](https://github.com/the-luap/picpeak/commit/916580adefd464417729e418aa1727b67566fea3))
## [3.43.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.7-beta.0...v3.43.0-beta.0) (2026-05-09)
### Features
* **footer:** hideable legal links + socials + promo banner ([#441](https://github.com/the-luap/picpeak/issues/441) + [#440](https://github.com/the-luap/picpeak/issues/440)) ([3a731e7](https://github.com/the-luap/picpeak/commit/3a731e7c95a75f6db42f903b1e6e560c3e024e6a))
* **gallery:** revoke customer-minted JWTs when assignment is removed ([55a5846](https://github.com/the-luap/picpeak/commit/55a5846f6f802a1bc1910bb046325fe272a1b584))
* **i18n:** add Spanish (es) locale ([#510](https://github.com/the-luap/picpeak/issues/510)) ([061712e](https://github.com/the-luap/picpeak/commit/061712ebf143a58102334201c09a52e4c96880f0))
* **install:** skip legacy chain when modern bootstrap fingerprint detected ([#530](https://github.com/the-luap/picpeak/issues/530)) ([8f0108c](https://github.com/the-luap/picpeak/commit/8f0108ce233f457d6a0f4f3dbc3e1b0a7217e74e))
* **lightbox:** medium-resolution preview tier ([#492](https://github.com/the-luap/picpeak/issues/492)) ([3083c74](https://github.com/the-luap/picpeak/commit/3083c748b92fe7800044125524d053f5eeb28d4a))
* **lightbox:** medium-resolution preview tier ([#492](https://github.com/the-luap/picpeak/issues/492)) ([61f1d13](https://github.com/the-luap/picpeak/commit/61f1d132104f485cfde9bd874e1c0ffdc042c4af))
* **lightbox:** multi-photo Web Share save-to-Photos on iOS ([#557](https://github.com/the-luap/picpeak/issues/557)) ([d5823c7](https://github.com/the-luap/picpeak/commit/d5823c79d9a187461c0126adcff7f4374cd0e8aa))
* **lightbox:** save photo to Photos app on mobile via Web Share ([#531](https://github.com/the-luap/picpeak/issues/531)) ([b2bbf7e](https://github.com/the-luap/picpeak/commit/b2bbf7efb5ded63e8c7438d5288d351cb9124ce8))
* **lightbox:** surface original camera filenames ([#508](https://github.com/the-luap/picpeak/issues/508)) ([33de294](https://github.com/the-luap/picpeak/commit/33de294d570ced267d89223cb398ef8bdf99e91d))
* **localization:** add French translations for fit options in thumbnails ([2c12885](https://github.com/the-luap/picpeak/commit/2c1288583fe787c1514c3cace0e2d92a212bb3a3))
* **localization:** add i18next configuration and CLI commands for localization management ([74e87b9](https://github.com/the-luap/picpeak/commit/74e87b968b3152603dbfca72fae06372b7c7f519))
* **localization:** add i18next extraction helper & refactor backup configuration component to tsx ([e7228b0](https://github.com/the-luap/picpeak/commit/e7228b07805a40aa67ccb7e3592848218eabbca2))
* **localization:** add missing translations ([86ee6c8](https://github.com/the-luap/picpeak/commit/86ee6c80aa1f26b2ad47775337ebed301193e662))
* **localization:** improve English translations for clarity and consistency ([46b99c6](https://github.com/the-luap/picpeak/commit/46b99c629215832e66d9215a210fd6eaf8c89fb4))
* **localization:** update thumbnail settings and add fit options translations ([5fc427c](https://github.com/the-luap/picpeak/commit/5fc427c74b5cdda6ce1568006d9a94abdc692b3b))
* **og:** per-event opt-in to use hero photo as social-share preview ([#474](https://github.com/the-luap/picpeak/issues/474)) ([d856340](https://github.com/the-luap/picpeak/commit/d856340f0d230ad26539ba1088f739f03aaafc13))
* **og:** per-event opt-in to use hero photo as social-share preview ([#474](https://github.com/the-luap/picpeak/issues/474)) ([0bc7e2a](https://github.com/the-luap/picpeak/commit/0bc7e2af171d1a4c6e91ba541d293b90c17c111b))
* **settings:** Features tab + sidebar reorg with feature-flag gating ([c3798e1](https://github.com/the-luap/picpeak/commit/c3798e19c8f928eac0ca1d7694d1ecfd78a1e437))
* **settings:** Features tab + sidebar reorg with feature-flag gating ([15e3336](https://github.com/the-luap/picpeak/commit/15e333681fe4ce94afa8e5b477b339b179d0195a))
* **translations:** add French language support and improve localization handling ([a5db4bd](https://github.com/the-luap/picpeak/commit/a5db4bd46e6a5cc84c9563588f90d3461c277528))
### Bug Fixes
* **activity-log:** smart feature_flags_updated rendering + 33 missing activity types ([4703fd5](https://github.com/the-luap/picpeak/commit/4703fd574f57bfab327ffefec5986bb08b240c95))
* **activity-log:** smart feature_flags_updated rendering + 33 missing types ([fad2de5](https://github.com/the-luap/picpeak/commit/fad2de5abe1da07bbc7503a462efac8f686029c7))
* **admin-users:** normalise date fields to ISO across DB drivers ([#485](https://github.com/the-luap/picpeak/issues/485)) ([d300426](https://github.com/the-luap/picpeak/commit/d3004263905edeffe59065e201f203a6768b4384))
* **admin-users:** normalise date fields to ISO across DB drivers ([#485](https://github.com/the-luap/picpeak/issues/485)) ([b6b58d0](https://github.com/the-luap/picpeak/commit/b6b58d0659fc8caee68a65e112780a1122d55907))
* **admin:** test email always sends, regardless of update availability ([#418](https://github.com/the-luap/picpeak/issues/418)) ([9326a42](https://github.com/the-luap/picpeak/commit/9326a427b32458dfdaa01530bac66cda84ed7b72))
* **admin:** test email always sends, regardless of update availability ([#418](https://github.com/the-luap/picpeak/issues/418)) ([c2b1854](https://github.com/the-luap/picpeak/commit/c2b1854df631354a977d737e8a00ddbc6fa8889f))
* **api/v1:** accept color_theme + create feedback row on event create ([#550](https://github.com/the-luap/picpeak/issues/550)) ([7ef0e40](https://github.com/the-luap/picpeak/commit/7ef0e40e7cee2ab6eeea4fe75c558e930e31241d))
* **api/v1:** accept color_theme + create feedback row on event create ([#550](https://github.com/the-luap/picpeak/issues/550)) ([1b521e7](https://github.com/the-luap/picpeak/commit/1b521e761c3e2cc6c885d03ef746aa7e77e6f067))
* **api/v1:** scope category lookup to event_owned or global ([92bb9e1](https://github.com/the-luap/picpeak/commit/92bb9e1a12f77ce5e8c1286716198362b2bfdff2))
* **auth:** default COOKIE_SECURE to 'auto' in production + first-install UX ([#427](https://github.com/the-luap/picpeak/issues/427)) ([e1c9382](https://github.com/the-luap/picpeak/commit/e1c93823c4a3095dd2afa618f393a061806f18a3))
* **auth:** default COOKIE_SECURE to 'auto' in production + first-install UX ([#427](https://github.com/the-luap/picpeak/issues/427)) ([5c7de96](https://github.com/the-luap/picpeak/commit/5c7de96b7fda9ca037a01b93fabe69d1be224893))
* **auth:** restore COOKIE_SECURE='auto' default for production ([adfa29e](https://github.com/the-luap/picpeak/commit/adfa29e91eeea52aa672e38269c389a5178d9e5a))
* **brand-title:** runtime substitution so GHCR-image users can override ([#521](https://github.com/the-luap/picpeak/issues/521) follow-up) ([efa6b4a](https://github.com/the-luap/picpeak/commit/efa6b4a2059f1da52ef435ec84bc548040dfa7e5))
* **branding:** socials + promo round-trip from DB to form ([#460](https://github.com/the-luap/picpeak/issues/460)) ([bd2288e](https://github.com/the-luap/picpeak/commit/bd2288e6a01786cec0649b3326189e9737db359e))
* **branding:** socials + promo round-trip from DB to form ([#460](https://github.com/the-luap/picpeak/issues/460)) ([ae64a6a](https://github.com/the-luap/picpeak/commit/ae64a6acbc119f78394e65cee7bf49910e1c7013))
* **bug-batch-518:** lightbox comments toggle + further fixes ([633a2ae](https://github.com/the-luap/picpeak/commit/633a2ae72405ae1fc885cb710ed896476ffee467))
* **categories:** strip diacritics from auto-generated slugs ([a747eb3](https://github.com/the-luap/picpeak/commit/a747eb351d1cf0ed269751cb685b504993d33dde))
* **categories:** strip diacritics from auto-generated slugs ([848430e](https://github.com/the-luap/picpeak/commit/848430e72b39f61a78e3f967770abe6df9a730f2))
* **ci:** pin TRIVY_PLATFORM per matrix arch (post-[#477](https://github.com/the-luap/picpeak/issues/477) follow-up) ([6750f5d](https://github.com/the-luap/picpeak/commit/6750f5d3b06f6312629e81c4c84100c572746b69))
* **ci:** pin TRIVY_PLATFORM per matrix arch (post-[#477](https://github.com/the-luap/picpeak/issues/477) follow-up) ([c3256dc](https://github.com/the-luap/picpeak/commit/c3256dc6bf49a2352dfe38b804d757683bb3ac22))
* **ci:** scan multi-arch images per-arch by digest, pin trivy-action ([#476](https://github.com/the-luap/picpeak/issues/476)) ([1144e9d](https://github.com/the-luap/picpeak/commit/1144e9d1625eb69d8e2a53b1a3aa1b515798fc80))
* **ci:** scan multi-arch images per-arch by digest, pin trivy-action ([#476](https://github.com/the-luap/picpeak/issues/476)) ([caf0d61](https://github.com/the-luap/picpeak/commit/caf0d618572b7fca0b28776ee13dcce2e0da0b99))
* **ci:** trivy-action tag is v0.36.0 (was 0.28.0 — does not exist) ([40e176c](https://github.com/the-luap/picpeak/commit/40e176cb46b2286d64a998937357b9e31cfaf04d))
* **create-event:** branding-default theme survives eventTypes refetch ([d62c529](https://github.com/the-luap/picpeak/commit/d62c529b0278a9ac790b22f46f49004df71112ea))
* **create-event:** branding-default theme survives eventTypes refetch ([#323](https://github.com/the-luap/picpeak/issues/323)-B) ([37d487d](https://github.com/the-luap/picpeak/commit/37d487db86fe9fc11facff6d2c0bb9a24e6f6277))
* **create-event:** re-apply Branding theme on stale→fresh settings ([#323](https://github.com/the-luap/picpeak/issues/323)-B) ([401abf7](https://github.com/the-luap/picpeak/commit/401abf7a27cb73dd7fb8399f4c05644c95767093))
* **customer-portal:** post-merge fixes for event save, theme fonts, and customer→gallery handoff ([9776d8a](https://github.com/the-luap/picpeak/commit/9776d8a6fcccb5e19e7c652b7e47557213d85731))
* **customer-routes:** Cache-Control: no-store on customer endpoints ([#470](https://github.com/the-luap/picpeak/issues/470)) ([3122dd0](https://github.com/the-luap/picpeak/commit/3122dd08a8bc08deb937236aaa3f750119a979b9))
* **customer:** customer sidebar active state matches admin pattern ([8d9d0be](https://github.com/the-luap/picpeak/commit/8d9d0bea836e80870c343220b1c253844ac01590))
* **customer:** don't log customer out on transient session-refresh errors ([9e418c7](https://github.com/the-luap/picpeak/commit/9e418c759ce508adf6025e0740468d8229938ffe))
* **customer:** preserve slug-scoped gallery tokens on auth provider mount ([7ac1d14](https://github.com/the-luap/picpeak/commit/7ac1d1473860796ea0925dd77454218f2b1f0020))
* **customer:** unwrap /customer/* from RequireFeature gate ([da08a58](https://github.com/the-luap/picpeak/commit/da08a5828ab855365a2a2a6f4854b09eb409907a))
* **downloads:** apply original-filename toggle to individual downloads too ([#507](https://github.com/the-luap/picpeak/issues/507)) ([38343e6](https://github.com/the-luap/picpeak/commit/38343e62ded3d65efcea5c74b7c1e169807754d6))
* **email-templates:** backfill subcategory + customer password reset translations ([2343a16](https://github.com/the-luap/picpeak/commit/2343a162df070cd5bd6abdc331bc5ca4aa283132))
* **email:** parse JSON-encoded language setting before using as locale ([ebc7da2](https://github.com/the-luap/picpeak/commit/ebc7da21bea90ff84f5351bef4fd3c2605d3a68f))
* **email:** parse JSON-encoded language setting before using as locale ([f12062f](https://github.com/the-luap/picpeak/commit/f12062f1e7be2974addbc50923f4427d3fba5a1c))
* **event:** correct updating client access ([d00f6fa](https://github.com/the-luap/picpeak/commit/d00f6fa7de59bdbd30efe9ce824e795cf05616f4))
* **event:** ensure client share token is generated only when necessary ([916580a](https://github.com/the-luap/picpeak/commit/916580adefd464417729e418aa1727b67566fea3))
* **events:** admins can clear expiration on edit even when 'Require expiration' is ON ([#426](https://github.com/the-luap/picpeak/issues/426)) ([3fd8af3](https://github.com/the-luap/picpeak/commit/3fd8af3d56b54f81cc20b75109cc212d23fc84c1))
* **events:** admins can clear expiration on edit even when "Require expiration" is ON ([#426](https://github.com/the-luap/picpeak/issues/426)) ([e544561](https://github.com/the-luap/picpeak/commit/e54456135cc8605fad949a955261cecc3f986355))
* **events:** clamp page state when totalPages drops below current page ([#442](https://github.com/the-luap/picpeak/issues/442)) ([b4e30a4](https://github.com/the-luap/picpeak/commit/b4e30a4293c77e6dc34e955741ee113b9d530718))
* **events:** clamp page state when totalPages drops below current page ([#442](https://github.com/the-luap/picpeak/issues/442)) ([9c4a96f](https://github.com/the-luap/picpeak/commit/9c4a96fe977b7a0907f5dea99491385195b76184))
* **events:** CustomerAccountPicker hooks order crashed /admin/events/new ([2a7ae07](https://github.com/the-luap/picpeak/commit/2a7ae0702dfc56e69bf845ded19888c29519414a))
* **events:** preserve branding inheritance when saving events with null color_theme ([d5a37df](https://github.com/the-luap/picpeak/commit/d5a37df2c41425511dc8a1f974088bebb768f0d5))
* **events:** strip customer_account_ids from update spread ([dde72a1](https://github.com/the-luap/picpeak/commit/dde72a1b1b3154e3effb9166291fdb8d59f16207))
* **events:** TDZ ReferenceError on /admin/events from [#442](https://github.com/the-luap/picpeak/issues/442) fix ([#454](https://github.com/the-luap/picpeak/issues/454)) ([2f63188](https://github.com/the-luap/picpeak/commit/2f63188a345ce8e337723045a60b3a9306a9a840))
* **events:** typed-DELETE confirmation for bulk delete ([#417](https://github.com/the-luap/picpeak/issues/417)) ([e165ee5](https://github.com/the-luap/picpeak/commit/e165ee5d9fa805c704a64f91c9514bf0ab75b5b8))
* **events:** typed-DELETE confirmation for bulk delete ([#417](https://github.com/the-luap/picpeak/issues/417)) ([99e420b](https://github.com/the-luap/picpeak/commit/99e420b1b9783a1d6b4eb892c09d0af3340bf314))
* **external-media:** pre-generate thumbnails so reference-mode galleries load fast ([#423](https://github.com/the-luap/picpeak/issues/423)) ([e2ffd9f](https://github.com/the-luap/picpeak/commit/e2ffd9f93d228f9e16408fe424c65b26db67ac8e))
* **external-media:** pre-generate thumbnails so reference-mode galleries load fast ([#423](https://github.com/the-luap/picpeak/issues/423)) ([f3d0f16](https://github.com/the-luap/picpeak/commit/f3d0f161c9e554a5149e6b4eafdb0ac42bebf277))
* **features-tab:** icon tiles + preview pills follow CI accent ([15d01f3](https://github.com/the-luap/picpeak/commit/15d01f375628d3c1ead05d48a09c6509c508a848))
* **features:** customer-portal card uses 'Clients' to match sidebar wording ([441cc41](https://github.com/the-luap/picpeak/commit/441cc419377055a5872f71afb19036cc5c932b58))
* **features:** customer-portal card uses 'Clients' to match sidebar wording ([dec2f5d](https://github.com/the-luap/picpeak/commit/dec2f5d3d2224b0d66f02bb9a562b4ddeaac77df))
* **feedback:** three guest-mode bugs from [#538](https://github.com/the-luap/picpeak/issues/538) (filter, like state, count leak) ([c900be9](https://github.com/the-luap/picpeak/commit/c900be92dd490b21aabb10fd56b6fbc3da444ee0))
* **feedback:** three guest-mode bugs reported in [#538](https://github.com/the-luap/picpeak/issues/538) ([5311588](https://github.com/the-luap/picpeak/commit/5311588baf3c6acfc971cb014a142d6b4b153aa1))
* **gallery:** hide Like button when guest feedback is off ([#506](https://github.com/the-luap/picpeak/issues/506)) ([9d2db9a](https://github.com/the-luap/picpeak/commit/9d2db9a73b71967fe4658c53d29ef5984f20bc69))
* **gallery:** serve thumbnails / photos / hero via storage abstraction ([#432](https://github.com/the-luap/picpeak/issues/432)) ([d3007b0](https://github.com/the-luap/picpeak/commit/d3007b0dd29d37a46ce26e8b4eb15908e0f8e3d2))
* **gallery:** serve thumbnails / photos / hero via storage abstraction ([#432](https://github.com/the-luap/picpeak/issues/432)) ([83d79f4](https://github.com/the-luap/picpeak/commit/83d79f4d39f2a68c8cb905cbd1b46d53bba80f49))
* **header:** hide language name on mobile to free the title ([#523](https://github.com/the-luap/picpeak/issues/523)) ([4b4ecfd](https://github.com/the-luap/picpeak/commit/4b4ecfdf7143c8f353355ecd6d5ee14bbf50c9bb))
* **i18n:** drive customer "Preferred language" select from SUPPORTED_LANGUAGES ([#510](https://github.com/the-luap/picpeak/issues/510)) ([51890e1](https://github.com/the-luap/picpeak/commit/51890e1aa5bacb5cfb5c9dc6e59770bd18406a66))
* **i18n:** settings page resets UI language to server default ([482e91b](https://github.com/the-luap/picpeak/commit/482e91bbf8b8deee361ffc8b031094fb99cc569d))
* **import:** capture photo dimensions in fileWatcher + s3AutoImporter ([#447](https://github.com/the-luap/picpeak/issues/447)) ([5b14854](https://github.com/the-luap/picpeak/commit/5b148542e6f2396ce47e3b6c301f186f9af9adec))
* **import:** capture photo dimensions in fileWatcher + s3AutoImporter ([#447](https://github.com/the-luap/picpeak/issues/447)) ([936a277](https://github.com/the-luap/picpeak/commit/936a277eb8695a54e65eaaa5a75ce43cff54c5db))
* **install:** defer events.hero_photo_id FK to break circular reference ([#484](https://github.com/the-luap/picpeak/issues/484)) ([62b3ed6](https://github.com/the-luap/picpeak/commit/62b3ed636414d358c0c73712b732207fc6fa1200))
* **install:** defer events.hero_photo_id FK to break circular reference ([#484](https://github.com/the-luap/picpeak/issues/484)) ([87834a7](https://github.com/the-luap/picpeak/commit/87834a7fff57a53bb1060ad7061dd6d279922f42))
* **install:** drop racy migration step + add missing frontend container ([#484](https://github.com/the-luap/picpeak/issues/484)) ([d4155c4](https://github.com/the-luap/picpeak/commit/d4155c46117eb1db6255ebac0ea47e6fc3e99801))
* **install:** self-chowning entrypoint kills fresh-install restart loop ([#484](https://github.com/the-luap/picpeak/issues/484)) ([42c5cda](https://github.com/the-luap/picpeak/commit/42c5cda38c0deeb4e61554e9e4a913bd5cd0b980))
* **install:** self-chowning entrypoint kills fresh-install restart loop ([#484](https://github.com/the-luap/picpeak/issues/484)) ([1505775](https://github.com/the-luap/picpeak/commit/15057756788eacc75dd9ff64541cac7418f368f2))
* **install:** silence clean-install postgres log noise ([#484](https://github.com/the-luap/picpeak/issues/484)) ([99e60a2](https://github.com/the-luap/picpeak/commit/99e60a243321a06f659d811babbcda9ffef655c4))
* **install:** silence clean-install postgres log noise ([#484](https://github.com/the-luap/picpeak/issues/484)) ([86b33d4](https://github.com/the-luap/picpeak/commit/86b33d4ddaf98e1f32473832b0f89565750174e5))
* **install:** silence pg healthcheck noise + drop legacy workers container ([#484](https://github.com/the-luap/picpeak/issues/484)) ([d39406b](https://github.com/the-luap/picpeak/commit/d39406b2414cdfcae84e8175d90821dd3a5287bb))
* **install:** silence pg healthcheck noise + drop legacy workers container ([#484](https://github.com/the-luap/picpeak/issues/484)) ([0b0b1bb](https://github.com/the-luap/picpeak/commit/0b0b1bb2d529dbaae8e49891d8d5e8019b971838))
* **install:** skip legacy chain on recovery-state DBs + schema-drift CI ([#530](https://github.com/the-luap/picpeak/issues/530)) ([a0ebc97](https://github.com/the-luap/picpeak/commit/a0ebc97cdd871041ff3cfdcc7276c413ac89d24f))
* **lightbox+events:** Android download lag, multi-photo Web Share re-land, theme branding inheritance ([e016f51](https://github.com/the-luap/picpeak/commit/e016f510b6cc57a9ed1b59e2ee24fedd5d7097c3))
* **lightbox:** align swipe-neighbour height + stop black flash on commit ([#505](https://github.com/the-luap/picpeak/issues/505)) ([d2d5509](https://github.com/the-luap/picpeak/commit/d2d55098d6d899c74b3b32b46b8194d06e7cda7e))
* **lightbox:** eliminate download lag on Android by skipping the blob round-trip ([0479521](https://github.com/the-luap/picpeak/commit/04795219a0b66fdd1ef73748d803adfdfc0d676f))
* **lightbox:** fill the heart icon when liked ([#538](https://github.com/the-luap/picpeak/issues/538) follow-up) ([3e39112](https://github.com/the-luap/picpeak/commit/3e39112a1276c194259d936dad813f3b0fc2dc3f))
* **lightbox:** fill the heart icon when liked ([#538](https://github.com/the-luap/picpeak/issues/538) follow-up) ([600c29d](https://github.com/the-luap/picpeak/commit/600c29db8a75fa44e72da55bc5288908de614d9d))
* **lightbox:** hide comments toggle when allow_comments=false ([#518](https://github.com/the-luap/picpeak/issues/518)) ([d44e1ad](https://github.com/the-luap/picpeak/commit/d44e1adba7a444b03511e9402cd39d25fe5acafe))
* **lightbox:** pan zoomed image with single-finger touch on mobile ([#532](https://github.com/the-luap/picpeak/issues/532)) ([53139b8](https://github.com/the-luap/picpeak/commit/53139b8cb87e0669fe38089f38848a59ce3cbb28))
* **lightbox:** restrict Web Share save-to-Photos path to iOS ([#554](https://github.com/the-luap/picpeak/issues/554)) ([578397b](https://github.com/the-luap/picpeak/commit/578397bc6b27b56ccf3bf1f2f244e0e0053c493a))
* **lightbox:** restrict Web Share save-to-Photos path to iOS ([#554](https://github.com/the-luap/picpeak/issues/554)) ([2a309c7](https://github.com/the-luap/picpeak/commit/2a309c75a74be3af3eb67758f8d65f801ef3019a))
* **nginx:** honour outer X-Forwarded-Proto when behind a reverse proxy ([#547](https://github.com/the-luap/picpeak/issues/547)) ([b351d17](https://github.com/the-luap/picpeak/commit/b351d17ee99528dd4251e74dfc47cd1fe289d9c3))
* **nginx:** honour outer X-Forwarded-Proto when behind a reverse proxy ([#547](https://github.com/the-luap/picpeak/issues/547)) ([5488de3](https://github.com/the-luap/picpeak/commit/5488de3383d33d8a037587dd9112d36ea035c465))
* **og:** brandable static title + wider crawler UA coverage ([#521](https://github.com/the-luap/picpeak/issues/521)) ([b960639](https://github.com/the-luap/picpeak/commit/b96063903513fcc4cbe0e72f59ccbc37d7b1c0ab))
* **promo-banner:** center by default + admin alignment selector ([#482](https://github.com/the-luap/picpeak/issues/482)) ([d1034ce](https://github.com/the-luap/picpeak/commit/d1034ce1c65c31b06005cfd0c047dba3251579ab))
* **promo-banner:** center by default + admin alignment selector ([#482](https://github.com/the-luap/picpeak/issues/482)) ([a803491](https://github.com/the-luap/picpeak/commit/a803491cf477c0d62e23d6a019e0d252e2c537f8))
* **public-site:** honor dark theme surface colors ([8b72721](https://github.com/the-luap/picpeak/commit/8b727218127db2a738ad5a4381358076c1575c8a))
* recover three orphaned commits from [#527](https://github.com/the-luap/picpeak/issues/527) (BRAND_TITLE runtime, Web Share, pan zoom) ([9607b46](https://github.com/the-luap/picpeak/commit/9607b4666c0abf22e54b43be3e87e3243db2cdbc))
* **security:** scan triage cleanup — drop dead deps, harden Docker/nginx/postMessage ([7abfeb9](https://github.com/the-luap/picpeak/commit/7abfeb91cc7bbb9b6853146dbfe16b8d9835bcb3))
* **security:** scan triage cleanup — drop dead deps, harden Docker/nginx/postMessage ([6b6191a](https://github.com/the-luap/picpeak/commit/6b6191a4260650e21c45f6153cac1b142bf8483a))
* **server:** drop missing requireCustomerPortal middleware import ([4fa7225](https://github.com/the-luap/picpeak/commit/4fa72257329942a6b598fa90c83c6bca7586fe33))
* **server:** mount /api/admin/feature-flags route ([f048011](https://github.com/the-luap/picpeak/commit/f048011324bfa4cee8f89b0131b68dd520446ca2))
* settings page resets UI language to server default ([165ebce](https://github.com/the-luap/picpeak/commit/165ebce8d1226cde22a36df28c0c45c2d79e2423))
* **settings:** neutralize sidebar icons for a consistent palette ([2f00bbd](https://github.com/the-luap/picpeak/commit/2f00bbdd90ef38feca886e834e753d4c4b60c11d))
* **settings:** readable contrast on accent-tinted icon tiles + pills ([bf7ef14](https://github.com/the-luap/picpeak/commit/bf7ef14626dc3a0e18f20813abe2a101d02c6d5b))
* **theme:** 'Same as body' heading font no longer inherits stale value ([35f5b86](https://github.com/the-luap/picpeak/commit/35f5b86d0f6a56627aac2abfe5228b5935709b08))
* **upload:** restore configurable batch-size for reverse proxies ([#509](https://github.com/the-luap/picpeak/issues/509)) ([98f3c3d](https://github.com/the-luap/picpeak/commit/98f3c3df4184b6d59b6c6b8e5f11b12b362320af))
* **upload:** wire drag-and-drop on admin + user upload zones ([#504](https://github.com/the-luap/picpeak/issues/504)) ([577c4bd](https://github.com/the-luap/picpeak/commit/577c4bdf29e107d11044a218ed5a6ba359e060c7))
### Reverts
* **customer-portal:** make the global flag UI-only, drop the kill-switch middleware ([3f44193](https://github.com/the-luap/picpeak/commit/3f4419356a4f30509052a6d00b71485af2c17f85))
### Documentation
* **contributing:** update branch reference from main to beta ([ed37caf](https://github.com/the-luap/picpeak/commit/ed37caf3d898d9b2db985e6c6ff203457fd4aa38))
* **contributing:** update branch reference from main to beta ([c114749](https://github.com/the-luap/picpeak/commit/c1147499212ef64e9d8ded89c38d84ab0adc5346))
* **localization:** enhance French language support and improve i18next configuration ([d1bc5e0](https://github.com/the-luap/picpeak/commit/d1bc5e030f55c15bf09f37b97f8e1608578a2395))
## [Unreleased]
## [3.43.1](https://github.com/the-luap/picpeak/compare/v3.43.0...v3.43.1) (2026-05-07)
### Bug Fixes
* **event:** fix updating client access ([ee85e1d](https://github.com/the-luap/picpeak/commit/ee85e1d))
* **security:** backport 18 dependency CVE patches from beta (3.42.2 stable) ([74eacbc](https://github.com/the-luap/picpeak/commit/74eacbc78f7efd5c499ae1647b716d3234096c39))
* **security:** patch 18 dependency CVEs (axios + transitives + nodemailer + i18next-http-backend) ([37bf894](https://github.com/the-luap/picpeak/commit/37bf894412b4da0f0507dd8f1384e6f101ce14b2))
## [3.43.0](https://github.com/the-luap/picpeak/compare/v3.42.1...v3.43.0) (2026-05-07)
### Features
* **i18n:** add French (fr) language support with full translation coverage
* **i18n:** add i18next configuration with language detection and namespace setup
* **i18n:** add CLI commands for localization management (extraction, validation)
* **i18n:** add `i18nextExtractionHelper` developer script for auditing missing translation keys
* **i18n:** complete and restructure translation files for EN, DE, NL, PT, RU with consistent key naming
### Code Refactoring
* **admin:** convert `BackupConfiguration`, `BackupDashboard`, and `BackupManagement` from JSX to TSX with full i18n support
* **admin:** remove stale `.d.ts` declaration files replaced by TSX components
* **i18n:** clean up `useLocalizedTimeAgo` hook and update `useLocalizedDate`
## [3.42.7-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.6-beta.0...v3.42.7-beta.0) (2026-05-09)
* add admin dark mode and SEO/robots.txt settings ([9c2a0d2](https://github.com/the-luap/picpeak/commit/9c2a0d272a21dfcace2ec795034e2f1adcba47e0))
* add bulk category editing for photos ([#157](https://github.com/the-luap/picpeak/issues/157)) ([eca36c7](https://github.com/the-luap/picpeak/commit/eca36c70a23f18f937a9f5bddeff855e18f364c3))
* add category hero/cover photo selection ([#163](https://github.com/the-luap/picpeak/issues/163)) ([6c30e2c](https://github.com/the-luap/picpeak/commit/6c30e2c2edd19a24d4f30a9558690bb7e2331b32))
* add configurable upload batch size for reverse proxy compatibility ([#208](https://github.com/the-luap/picpeak/issues/208)) ([02a46e0](https://github.com/the-luap/picpeak/commit/02a46e083d68cfdb355b5a4fe4a8da7d667050b9))
* add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments ([#298](https://github.com/the-luap/picpeak/issues/298)) ([b1dfbe4](https://github.com/the-luap/picpeak/commit/b1dfbe4c2fe271d8087974d02cf724f04058bdc9))
* add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments ([#298](https://github.com/the-luap/picpeak/issues/298)) ([15a8ab4](https://github.com/the-luap/picpeak/commit/15a8ab41fd1c94e3397d300b161cd1fdd459ea05))
* add customizable event types with admin management ([f8881d5](https://github.com/the-luap/picpeak/commit/f8881d5bd62d449fb40917ec8c20f0eb16c1fdad))
* add Dutch (nl) locale and fix missing translation keys across all locales ([b54a80d](https://github.com/the-luap/picpeak/commit/b54a80d251bcbb9a126e32eeaef522688bc810c6))
* add Dutch locale and fix missing translation keys ([e32da68](https://github.com/the-luap/picpeak/commit/e32da68cbdfa430d62cbb1057ea418dc6b2f14fb))
* add Gallery Premium and Gallery Story layouts (Beta) ([e179def](https://github.com/the-luap/picpeak/commit/e179def3cceefe5fd6acd5574f2986e4f9e223ef))
* add hero image focal point picker with anchor positioning ([#162](https://github.com/the-luap/picpeak/issues/162)) ([734868a](https://github.com/the-luap/picpeak/commit/734868abc23731b0ac9ad73e799194df1e6aa6ab))
* add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([608bbd5](https://github.com/the-luap/picpeak/commit/608bbd50e7b31d49c7516a00e96f284fa16e2777))
* Add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([ef2ae00](https://github.com/the-luap/picpeak/commit/ef2ae00ff20b754c2f2ed797e18c146d12d7f31a))
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) ([e081b56](https://github.com/the-luap/picpeak/commit/e081b56a44bf9fdaa3dd225d5dd4dde35bfe83d3))
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) + security fixes ([cd1d504](https://github.com/the-luap/picpeak/commit/cd1d50474f673b759c2f9401fdbe209a84773e39))
* add optional event date and expiration settings ([3079eaa](https://github.com/the-luap/picpeak/commit/3079eaa2e5d1728c2c0f315626cc253e4b08edc2))
* add optional event date and expiration settings ([2151147](https://github.com/the-luap/picpeak/commit/2151147f2d3134448ff32130da44678e2942d73c)), closes [#118](https://github.com/the-luap/picpeak/issues/118)
* add original filename preservation and Lightroom export support ([a59f414](https://github.com/the-luap/picpeak/commit/a59f41463f960a3a74ce3933dc7db84ee3a2018d))
* add original filename preservation and Lightroom export support ([9872ad3](https://github.com/the-luap/picpeak/commit/9872ad3aef6488b359c5499a6dc3d8bfbfa48fde))
* add per-event custom logo upload with bug fixes ([85170b8](https://github.com/the-luap/picpeak/commit/85170b883f504d83f1d862abb3f4e46741074826))
* add per-event hero logo customization options ([0790a1d](https://github.com/the-luap/picpeak/commit/0790a1ddad774af89827a0a392e9fae0a945bff2))
* add per-gallery thumbnail scale setting ([#172](https://github.com/the-luap/picpeak/issues/172)) ([#251](https://github.com/the-luap/picpeak/issues/251)) ([ee46088](https://github.com/the-luap/picpeak/commit/ee46088985ebbbb81d16e5bac23be2060c94397f))
* add photo cap per event and Portuguese (pt-BR) locale ([1fa222e](https://github.com/the-luap/picpeak/commit/1fa222e9c4c26e525c7899e368988c6b0b08da85))
* add photo cap per event and Portuguese locale ([088de43](https://github.com/the-luap/picpeak/commit/088de43f09f974d444f50452ef1117315c289ebc))
* add quilted layout, fix mosaic, and backfill photo dimensions ([#146](https://github.com/the-luap/picpeak/issues/146)) ([46ed1bc](https://github.com/the-luap/picpeak/commit/46ed1bc276867a25b27bf22cd9b9d7e879a6947b))
* add thumbnail settings UI to admin panel ([3a30fea](https://github.com/the-luap/picpeak/commit/3a30fea862034d64fbc7188fc25292594a9319e2))
* add thumbnail settings UI to admin settings page ([#206](https://github.com/the-luap/picpeak/issues/206)) ([7d6d2f5](https://github.com/the-luap/picpeak/commit/7d6d2f56883a4402f0d97c95b0432a8a783c8024))
* add update instructions dialog, email notifications, and capture date sorting ([50c0990](https://github.com/the-luap/picpeak/commit/50c09904a9434f988ab32a07da5d24db0e02065e)), closes [#181](https://github.com/the-luap/picpeak/issues/181)
* add visual WYSIWYG email template editor ([#229](https://github.com/the-luap/picpeak/issues/229)) ([04a7ea8](https://github.com/the-luap/picpeak/commit/04a7ea80f95d6aeb474b145292e75f45fb85c66d))
* **branding:** 8-token CI palette + force color mode + dark-mode consistency ([8050927](https://github.com/the-luap/picpeak/commit/80509276074b8125b6d676839afabb0b6f89206f))
* **branding:** force color mode (dark or light) site-wide ([5a162fc](https://github.com/the-luap/picpeak/commit/5a162fc8bec47a49cb1bcaa92ff72e197e8d2e42))
* **branding:** inline force color mode with auto-save + clearer palette help text ([67d7d8d](https://github.com/the-luap/picpeak/commit/67d7d8d3fa25ceab0eda02b291f2e220b222f84a))
* **branding:** per-family generic fallback via meta.json ([dcff451](https://github.com/the-luap/picpeak/commit/dcff4515721482e06c2ef1c1eb34f9e12754c07c))
* **branding:** preview each font in its own face in the picker dropdown ([b4f9b65](https://github.com/the-luap/picpeak/commit/b4f9b65f1df4c400f22b28f20e1304ec57279d33))
* **branding:** self-hosted webfonts with filesystem scanner ([d04bf28](https://github.com/the-luap/picpeak/commit/d04bf288084144bf53ef0ba988fa32ed703d7351))
* **branding:** self-hosted webfonts with filesystem scanner ([bac51fe](https://github.com/the-luap/picpeak/commit/bac51fe69a39f85381f445e8da6cd63cf5826fc4))
* **cms:** add external URL toggle for imprint and privacy pages ([b2c8161](https://github.com/the-luap/picpeak/commit/b2c8161a43c2d0b09d6783e791b3f26862254824))
* **cms:** add per-page external URL override — backend ([66423bb](https://github.com/the-luap/picpeak/commit/66423bb65e83b6204509c9a98d783ba8255c3364))
* **cms:** admin UI for external imprint/privacy URL ([a4e3d10](https://github.com/the-luap/picpeak/commit/a4e3d10fb0c97ea07c4b08d16c0947945d8a7576))
* **cms:** redirect legal links to external URL when configured ([c5bba50](https://github.com/the-luap/picpeak/commit/c5bba505ac92b5257f6bb1c069b8bc23ea6a5a1b))
* configurable upload batch size for reverse proxy compatibility ([9b7495e](https://github.com/the-luap/picpeak/commit/9b7495e0054975e66c9b5006c24a9fae63969de4))
* configurable upload batch size for reverse proxy compatibility ([4243363](https://github.com/the-luap/picpeak/commit/424336340bef8e1629490ade154f0ceebb2a71e1))
* customisable 404 + gallery-not-found pages via CMS ([#324](https://github.com/the-luap/picpeak/issues/324)) ([4f77905](https://github.com/the-luap/picpeak/commit/4f77905b87bea474b3d2496350996deaad041230))
* decouple hero header from gallery layouts ([#158](https://github.com/the-luap/picpeak/issues/158)) ([7b8d8bd](https://github.com/the-luap/picpeak/commit/7b8d8bd92ba7a96717bb4d821b38dddc395f701a))
* draft mode, admin branding, and workflow improvements ([dc98206](https://github.com/the-luap/picpeak/commit/dc98206737d1ebe43637319ce8c5b6da2e44c05d))
* draft mode, admin branding, and workflow improvements ([40332a7](https://github.com/the-luap/picpeak/commit/40332a71db6534097940d3f9362b0fe651dba6c7))
* dynamic website title from branding settings ([d29aab7](https://github.com/the-luap/picpeak/commit/d29aab7c70c5777451666fb7d5c7a9729dab684a))
* **email:** expand email palette to 8 tokens + Sync from Branding button ([47b6b39](https://github.com/the-luap/picpeak/commit/47b6b39f3a942aee93b970031d95a952cb769d09))
* **events:** add Photos column to admin events list ([#384](https://github.com/the-luap/picpeak/issues/384)) ([d561db8](https://github.com/the-luap/picpeak/commit/d561db802b04db8fbb38819a22e840532e775ef0))
* **events:** add Photos column to admin events list ([#384](https://github.com/the-luap/picpeak/issues/384)) ([ffb4318](https://github.com/the-luap/picpeak/commit/ffb4318a1f667e273cd59805673b125d2f17699b))
* **events:** bulk delete with password confirmation ([#384](https://github.com/the-luap/picpeak/issues/384)) ([647aea2](https://github.com/the-luap/picpeak/commit/647aea21ae42fe0d089bf45568702617a25b98e4))
* **events:** bulk delete with password confirmation ([#384](https://github.com/the-luap/picpeak/issues/384)) ([48d538f](https://github.com/the-luap/picpeak/commit/48d538f94fd39d9b85ec57c57301a8490b7d4f6d))
* **events:** prefill admin email + admin picker on event creation ([3fe8e61](https://github.com/the-luap/picpeak/commit/3fe8e61bd1175e35dcb61604447e5d9c2e902ec5))
* **events:** prefill admin email + admin picker on event creation ([ee56b67](https://github.com/the-luap/picpeak/commit/ee56b6762f5b2eb9ea42f4abe4dde4356e2e54e6))
* **events:** Sync from Branding button in gallery theme customizer + clarified default inheritance ([bdbe7b8](https://github.com/the-luap/picpeak/commit/bdbe7b80a13b8b215ac544ba9105100e792eeda2))
* **events:** tree view for external media folder picker ([cdd40ac](https://github.com/the-luap/picpeak/commit/cdd40acb4591d4eb8f80a79c69201556eab1bfd0))
* **events:** tree view for external media folder picker ([f927b09](https://github.com/the-luap/picpeak/commit/f927b09c70f3b6a5c81c3726609a29680b96b6fc))
* **frontend:** dedupe /public/settings via shared usePublicSettings hook ([#325](https://github.com/the-luap/picpeak/issues/325)) ([3d4ae4d](https://github.com/the-luap/picpeak/commit/3d4ae4d7e9f9995d93563e8092e05215362afb3b))
* gallery layouts, bulk category editing, and hero header improvements ([7037106](https://github.com/the-luap/picpeak/commit/7037106bff62593bba600d898a781f79f07b459d))
* gallery layouts, hero customization, bulk categories & event types ([d9e00dc](https://github.com/the-luap/picpeak/commit/d9e00dc0dbd7cef0ddb4665e5306c98aac3573e3))
* gallery layouts, hero customization, event types, and UX improvements ([#146](https://github.com/the-luap/picpeak/issues/146), [#155](https://github.com/the-luap/picpeak/issues/155)-163, [#170](https://github.com/the-luap/picpeak/issues/170), [#171](https://github.com/the-luap/picpeak/issues/171)) ([4280444](https://github.com/the-luap/picpeak/commit/4280444d70e73db09e67e18ce25bac75cf499b75))
* **gallery:** decouple header style from layout, add banner option ([1f1a856](https://github.com/the-luap/picpeak/commit/1f1a856083b1966ed4b32a23a14442f1727cecef))
* **gallery:** decouple header style from layout, add banner option ([24d7277](https://github.com/the-luap/picpeak/commit/24d727752c442263a6469e0aefa666454a4c652f))
* **gallery:** decouple header style from layout, add banner option ([aff29c9](https://github.com/the-luap/picpeak/commit/aff29c91bbb250debe74e2a512047ee40e157a34))
* **gallery:** icon-only menu, accent Download CTA ([#386](https://github.com/the-luap/picpeak/issues/386)) ([876b35b](https://github.com/the-luap/picpeak/commit/876b35b4a512f70cfc19561e35ce9d915a599547))
* **gallery:** icon-only menu, accent Download CTA, logo aligned ([#386](https://github.com/the-luap/picpeak/issues/386)) ([de8ad5f](https://github.com/the-luap/picpeak/commit/de8ad5fdd5ce1b9552ca8ca6e405d15c7372a4c8))
* guest selections with per-person identity ([#292](https://github.com/the-luap/picpeak/issues/292)) ([3856ba2](https://github.com/the-luap/picpeak/commit/3856ba25bbce971b07bba4dad19e7bdceca98cab))
* guest selections with per-person identity ([#292](https://github.com/the-luap/picpeak/issues/292)) ([ad4e5a7](https://github.com/the-luap/picpeak/commit/ad4e5a7506bc9217d1223101da0bc112047532a8))
* **i18n:** add Brazilian Portuguese (pt-BR) locale ([375f512](https://github.com/the-luap/picpeak/commit/375f51285b5db9c0dfcc04761d24927282e57796))
* **i18n:** improve pt locale with pt-BR phrasings, remove duplicate pt-BR file ([f25559c](https://github.com/the-luap/picpeak/commit/f25559c0e76776f7cfe8d187e1fea05e751bbafe))
* improve gallery layouts with aspect-ratio-aware masonry and mosaic modes ([#146](https://github.com/the-luap/picpeak/issues/146)) ([aacfcd5](https://github.com/the-luap/picpeak/commit/aacfcd517ea5739e834cf84627b55b3449740a5c))
* improve hero image UX and live preview ([#163](https://github.com/the-luap/picpeak/issues/163), [#158](https://github.com/the-luap/picpeak/issues/158)) ([d63f67a](https://github.com/the-luap/picpeak/commit/d63f67a2afba1b92610382aa1012428ccacb86bd))
* multilingual email templates with translations table ([8c5996e](https://github.com/the-luap/picpeak/commit/8c5996e4ec43b2817d84cc040cfe52878ffb61d5))
* multilingual email templates with translations table ([f50d7c0](https://github.com/the-luap/picpeak/commit/f50d7c0c51aa84a2182e450cd4b6a00777a8f9c0))
* native multi-arch Docker images (Apple Silicon, ARM64 Linux) ([df30618](https://github.com/the-luap/picpeak/commit/df3061893d154152b75b8ab0d07e0b1e0078431d))
* native S3 storage backend ([#328](https://github.com/the-luap/picpeak/issues/328)) + presigned download follow-up ([1b717ce](https://github.com/the-luap/picpeak/commit/1b717ce5ededa343d2fbb7e1c3493b4434743565))
* new features and bug fixes for beta release ([151e1bf](https://github.com/the-luap/picpeak/commit/151e1bf50f206ae0571fa044c75b8bc9f0f40120))
* optional customer phone field gated by global toggle ([#322](https://github.com/the-luap/picpeak/issues/322)) ([be6cb28](https://github.com/the-luap/picpeak/commit/be6cb28c8097d2277c1af2a32cf8bc88ebbc7136))
* original filename in admin UI, update dialog, and security hardening ([3ea9d5b](https://github.com/the-luap/picpeak/commit/3ea9d5b1219980032cbee7a2564c0004948923f5))
* original filename in admin UI, update dialog, security hardening, and bug fixes ([bcf2745](https://github.com/the-luap/picpeak/commit/bcf2745ab64acb968ae4bd0710b28e78c14f340c))
* outbound webhooks for event/photo lifecycle ([#327](https://github.com/the-luap/picpeak/issues/327)) ([c488f48](https://github.com/the-luap/picpeak/commit/c488f481caacf0d63dafc47f509e8de2708bc30f))
* per-event custom logos, customizable event types, and multiple bug fixes ([4c08160](https://github.com/the-luap/picpeak/commit/4c081601e02888d7ad289acb7847aee9d6f5703f))
* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([4a93e4e](https://github.com/the-luap/picpeak/commit/4a93e4e8cbe1b7a23a8be706291a270ccdf5bb55))
* photo visibility control with client access ([#172](https://github.com/the-luap/picpeak/issues/172)) ([e1b6e43](https://github.com/the-luap/picpeak/commit/e1b6e43e524211c913d3d29ade5fc029df12920f))
* pre-generate watermarks for instant lightbox loading ([1be974a](https://github.com/the-luap/picpeak/commit/1be974afbb0b7a1bdbdd140327771907a5d3c2ae)), closes [#112](https://github.com/the-luap/picpeak/issues/112)
* pre-generated watermarks and mobile upload button improvements ([c6fdd38](https://github.com/the-luap/picpeak/commit/c6fdd38e842e1a8c0aa9cbab9fc791e6669e402d))
* pre-zip download all and photo replacement by name ([#312](https://github.com/the-luap/picpeak/issues/312), [#313](https://github.com/the-luap/picpeak/issues/313)) ([d3f1206](https://github.com/the-luap/picpeak/commit/d3f12068164a6bfe6c4a3817ad2fc2e8ed7abf4f))
* pre-zip download all and photo replacement by name ([#312](https://github.com/the-luap/picpeak/issues/312), [#313](https://github.com/the-luap/picpeak/issues/313)) ([e18afd3](https://github.com/the-luap/picpeak/commit/e18afd3e6b0b5a4cdb4873fb227d1b1d2bf35f21))
* presigned download UI + S3 prefix walker auto-importer (follow-ups) ([446d80a](https://github.com/the-luap/picpeak/commit/446d80a4cc5eb0389994e29585b2a98dad373db2))
* public v1 API + token management + OpenAPI docs ([#322](https://github.com/the-luap/picpeak/issues/322)) ([808b15b](https://github.com/the-luap/picpeak/commit/808b15bafbcdab6ea55aff7f0e507153f513a70a))
* register Russian locale and add to language selector ([6f95b8c](https://github.com/the-luap/picpeak/commit/6f95b8c26cd794525e15e45d478f9ead0ec22555))
* S3 storage + webhooks + settings dedupe + backup fixes ([06d54be](https://github.com/the-luap/picpeak/commit/06d54bec4d0afc4a1b9ba6f2449ed7d79f1d3e8f))
* show original filename in admin UI ([#184](https://github.com/the-luap/picpeak/issues/184)) ([0891be1](https://github.com/the-luap/picpeak/commit/0891be197fdb7d92ade5a293b8db0bed26fa6e3a))
* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([8805fa5](https://github.com/the-luap/picpeak/commit/8805fa53e61c6b3672a8f6dad14d2fd17998a451))
* sort photos by capture date with configurable default sort ([#283](https://github.com/the-luap/picpeak/issues/283)) ([633d4a0](https://github.com/the-luap/picpeak/commit/633d4a0f301e355ee9f057347f2f8dee8c5b4163))
* support Apple Silicon natively via multi-arch images ([c282a72](https://github.com/the-luap/picpeak/commit/c282a72bd35db062cec25770a42cf9c803388e44))
* **theme:** expand color settings to 8-token CI palette + alt button ([114aab5](https://github.com/the-luap/picpeak/commit/114aab57771a4bba03a9e5c616c75a37c9b25969))
* **upload:** async photo processing — backend (PR-B part 1) ([851744c](https://github.com/the-luap/picpeak/commit/851744c3c4df7deba8d946b6592fdb5042c52a26))
* **upload:** async photo processing — frontend (PR-B part 2) ([3b827b8](https://github.com/the-luap/picpeak/commit/3b827b80d51269e1a7b9c693396f3cb7a9a48ffc))
* **upload:** async photo processing + fix(auth): /auth/session symmetry (loop fix) ([907bcf1](https://github.com/the-luap/picpeak/commit/907bcf1eb2d44ded149a1caf39ee1cfe63fec994))
* **upload:** two-state UI + temp dir cleanup (PR-A of async processing) ([86dfcc4](https://github.com/the-luap/picpeak/commit/86dfcc4f116a394e7e092ab3e01f3f1d030bb367))
* visual WYSIWYG email template editor ([703c03f](https://github.com/the-luap/picpeak/commit/703c03fbee754a5291b57b885c5e82fbdd3e69e9))
* warn about low thumbnail resolution when selecting beta themes ([ee3f6ae](https://github.com/the-luap/picpeak/commit/ee3f6ae13bf9c9fb3295286e84150e04bf9fbce4))
* warn about low thumbnail resolution with beta themes ([aef9b4e](https://github.com/the-luap/picpeak/commit/aef9b4ed7fc443cbec8890c580759077e05e77b4))
* **webhooks:** enrich event.* payloads with customer contact + share_token ([#341](https://github.com/the-luap/picpeak/issues/341)) ([7ea4801](https://github.com/the-luap/picpeak/commit/7ea4801544fd5cd8bca1907a71b5c4e96ee77649))
* **webhooks:** enrich event.* payloads with customer contact + share_token ([#341](https://github.com/the-luap/picpeak/issues/341)) ([1e69d5f](https://github.com/the-luap/picpeak/commit/1e69d5ff71ac2d1d133b0e40637b437d7cc8bc4f))
### Bug Fixes
* **auth:** default COOKIE_SECURE to 'auto' in production + first-install UX ([#427](https://github.com/the-luap/picpeak/issues/427)) ([e1c9382](https://github.com/the-luap/picpeak/commit/e1c93823c4a3095dd2afa618f393a061806f18a3))
## [3.42.6-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.5-beta.0...v3.42.6-beta.0) (2026-05-09)
* add allow_user_uploads to gallery API responses ([691e3ab](https://github.com/the-luap/picpeak/commit/691e3aba09f2148afe902a0bb0139d062634e669))
* add lightbox loading spinner and watermark cache invalidation ([050ed37](https://github.com/the-luap/picpeak/commit/050ed378199eb3b15c7c7f243792f68f858803f5))
* add STORAGE_PATH to production docker-compose ([cdda709](https://github.com/the-luap/picpeak/commit/cdda70988664a177b351abc6a259ec39664d17ff))
* address beta feedback - gallery layout fixes, Russian locale, email logo ([#249](https://github.com/the-luap/picpeak/issues/249)) ([486239a](https://github.com/the-luap/picpeak/commit/486239aeb9b5f56551d5aa90f0bad3008eedc3bb))
* address bugs and feature requests from discussion [#317](https://github.com/the-luap/picpeak/issues/317) ([6cfff6f](https://github.com/the-luap/picpeak/commit/6cfff6f6a6dbdc5bc1e9fe4fbce5795cdb1855c6))
* address Shannon security assessment findings (37 vulnerabilities) ([#254](https://github.com/the-luap/picpeak/issues/254)) ([23cd9cb](https://github.com/the-luap/picpeak/commit/23cd9cb680eb77b94a97266c3353dfc835f0cc69))
* admin photo feedback filters have no effect ([#293](https://github.com/the-luap/picpeak/issues/293)) ([9ed8a2b](https://github.com/the-luap/picpeak/commit/9ed8a2b1994d139efd100c8fb97e6368655e5530))
* **admin:** tab underlines use accent (not accent-dark) for proper highlight color ([565ae45](https://github.com/the-luap/picpeak/commit/565ae45ca71e46166c8bbfc0eb0b6da92d74f120))
* apply password change fix to regular modal + longer toast delay ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c63bc47](https://github.com/the-luap/picpeak/commit/c63bc47089b4b32c570bdeeb1f82bf722569875f))
* apply password change redirect fix to regular modal too ([#263](https://github.com/the-luap/picpeak/issues/263)) ([147dc28](https://github.com/the-luap/picpeak/commit/147dc28440ca69ed970677fa221dfac00c8e2560))
* apply sort direction in gallery and respect show_feedback_to_guests ([#302](https://github.com/the-luap/picpeak/issues/302), [#303](https://github.com/the-luap/picpeak/issues/303)) ([3716ff5](https://github.com/the-luap/picpeak/commit/3716ff50854766bde588fbd6b9027f8647e59150))
* apply sort direction in gallery view and respect show_feedback_to_guests ([#302](https://github.com/the-luap/picpeak/issues/302), [#303](https://github.com/the-luap/picpeak/issues/303)) ([dffe057](https://github.com/the-luap/picpeak/commit/dffe057772c922ab6a213e25f171157e0c2badf8))
* **auth:** /auth/session must enforce session timeout symmetrically ([#350](https://github.com/the-luap/picpeak/issues/350) recurrence) ([c8e09c2](https://github.com/the-luap/picpeak/commit/c8e09c2a2a7d0920901560317eecd773b83251c0))
* **auth:** /auth/session must enforce session timeout symmetrically ([#350](https://github.com/the-luap/picpeak/issues/350) recurrence) ([b106da1](https://github.com/the-luap/picpeak/commit/b106da1ededa27fc8727f2c0e74a9182e6e9c895))
* **auth:** /auth/session must reject tokens that adminAuth/galleryAuth would reject ([f905f7e](https://github.com/the-luap/picpeak/commit/f905f7e7336c756e73a8c650c9239b171697164a))
* **auth:** /auth/session must verify issuer claim like adminAuth ([#350](https://github.com/the-luap/picpeak/issues/350)) ([83dedbc](https://github.com/the-luap/picpeak/commit/83dedbcd45e34a924594dd83f6e3561f776576fb))
* **auth:** make /auth/session verify the issuer claim like adminAuth ([#350](https://github.com/the-luap/picpeak/issues/350)) ([88a6c6a](https://github.com/the-luap/picpeak/commit/88a6c6a7fba7e1419a021f4870518f0b76ac6494))
* **backup:** cron schedule mapping + manifest format detection + bigint coerce ([ab4095f](https://github.com/the-luap/picpeak/commit/ab4095f5928b1476009cddfd3444d6f5b58b034d))
* **backup:** incremental backups against S3 + jsonb stats parsing ([e232f9f](https://github.com/the-luap/picpeak/commit/e232f9f2cf54aeba1e16d769397428206a0f1801))
* **branding:** admin sidebar uses accent-dark, primary buttons follow CI token ([fc2bce3](https://github.com/the-luap/picpeak/commit/fc2bce3a01f02b2d131ca4ce1c8e81fc9dc62755))
* **branding:** comprehensive sweep — replace remaining primary-* legacy colors with accent tokens ([578a174](https://github.com/the-luap/picpeak/commit/578a1745b8d010eeeb261d3452fd192b1ec7bcf8))
* **branding:** selected-state accent colors, force-mode actually flips galleries, compact color picker layout ([5b410ed](https://github.com/the-luap/picpeak/commit/5b410ed9f87daad8e96345a86897f2a9e9419802))
* **branding:** working tooltips, high-contrast selected states, gallery chrome follows accent ([b19bb0c](https://github.com/the-luap/picpeak/commit/b19bb0c6208744f329cb3e99f4e26a83f191710a))
* checkbox and toggle settings not persisting after page refresh ([808ed1d](https://github.com/the-luap/picpeak/commit/808ed1d2f1164d9fd1114586c68a1f925bf73ddf)), closes [#117](https://github.com/the-luap/picpeak/issues/117)
* **cms:** apply dark mode to CMS editor, public CMS, and admin modals ([d2a10f6](https://github.com/the-luap/picpeak/commit/d2a10f6523655488267d6f68835d7adb46dcf962))
* **cms:** nl/pt/ru i18n + gate external_url in public response ([08d0462](https://github.com/the-luap/picpeak/commit/08d046276bf259e8511b01415141f51b8484f967))
* **cms:** nl/pt/ru i18n + gate external_url in public response ([bce5c1f](https://github.com/the-luap/picpeak/commit/bce5c1f725043965c2499515f18e93e9578bd204))
* correct invitation activation validation and add missing translations ([991aa98](https://github.com/the-luap/picpeak/commit/991aa98f98cffd1d7785c272726615325e2c0208)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
* correct invitation email link URL path ([86fa104](https://github.com/the-luap/picpeak/commit/86fa1046d5439cb451feb164175c919c49ca219a)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([0e3674b](https://github.com/the-luap/picpeak/commit/0e3674b2b0325bbcee5aa2c9ff7781da92f612d1))
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([3ccb815](https://github.com/the-luap/picpeak/commit/3ccb8154eb40a432aa467fb06b3f216fd0d2c6b4))
* database migration restart bug, lightbox loading spinner, and watermark cache invalidation ([7c58749](https://github.com/the-luap/picpeak/commit/7c5874980640ae8c3d1050ce24daeb0a2aeab7a3))
* dedupe parallel admin 401 redirects to /admin/login ([038e84c](https://github.com/the-luap/picpeak/commit/038e84cae7f56a0a1af8c71b85881ca5d320c6e3))
* discussion [#317](https://github.com/the-luap/picpeak/issues/317) issues and [#318](https://github.com/the-luap/picpeak/issues/318) archive crash ([2f2f405](https://github.com/the-luap/picpeak/commit/2f2f405d9bc2831b3bbe2ca7fbf726d61382dc38))
* display welcome message in gallery and fix guest thumbnail URLs ([#306](https://github.com/the-luap/picpeak/issues/306), [#307](https://github.com/the-luap/picpeak/issues/307)) ([b05c36a](https://github.com/the-luap/picpeak/commit/b05c36ac810a557a2ac088ab7bec39bb76f9a2ae))
* display welcome message in gallery and fix guest thumbnail URLs ([#306](https://github.com/the-luap/picpeak/issues/306), [#307](https://github.com/the-luap/picpeak/issues/307)) ([9323bef](https://github.com/the-luap/picpeak/commit/9323befdd99d64b85cca89af24ac1b7034d72eee))
* docker compose v2 syntax and add missing ADMIN_PASSWORD to .env.example ([#189](https://github.com/the-luap/picpeak/issues/189)) ([0817443](https://github.com/the-luap/picpeak/commit/0817443e793e37c770c6a1968ecae4b9464107b0))
* **docker:** install system ffmpeg on Alpine, drop broken bundled binary ([3ab8a64](https://github.com/the-luap/picpeak/commit/3ab8a64a24f1600e674f77d39139e33857b4dfc8))
* **docker:** install system ffmpeg on Alpine, drop broken bundled binary ([96818c7](https://github.com/the-luap/picpeak/commit/96818c7ae8de0d8fd478cd901ea25a3272eee85d))
* dynamic website title from branding settings ([4701edc](https://github.com/the-luap/picpeak/commit/4701edc12ecfab27cb2d1cfb0b4ed4fd53f56cc6))
* **email:** render conditionals, localise password placeholders, fix caller/template variable drift ([0767203](https://github.com/the-luap/picpeak/commit/07672038d4ac31fc601adfb2338104223856ba71))
* **email:** render conditionals, localise password placeholders, fix caller/template variable drift ([e8052ad](https://github.com/the-luap/picpeak/commit/e8052adf1d2f1717652ac5d6b8cd8bcc01787189))
* event-specific custom CSS settings not being saved ([dadef81](https://github.com/the-luap/picpeak/commit/dadef81158972d28aa32812203500f77ed08a999)), closes [#136](https://github.com/the-luap/picpeak/issues/136)
* events search/counters ([#346](https://github.com/the-luap/picpeak/issues/346)), lazy gallery skeleton ([#321](https://github.com/the-luap/picpeak/issues/321)), smooth lightbox swipe ([#348](https://github.com/the-luap/picpeak/issues/348)) ([6229b38](https://github.com/the-luap/picpeak/commit/6229b38bac90cc0c538a72688efae3be77a3bb08))
* events without expiration date incorrectly shown as expired ([c4f16eb](https://github.com/the-luap/picpeak/commit/c4f16eb76c909158abdb63aa4cc22f817f274dc5))
* **events:** admin-set password on reset, full-URL gallery_link in all emails ([0d1f82d](https://github.com/the-luap/picpeak/commit/0d1f82d31a2f9e30bf193496ac203eaf8dfd856b))
* **events:** admin-set password on reset, full-URL gallery_link in all emails ([ff50c74](https://github.com/the-luap/picpeak/commit/ff50c74e1912ccba60f7ccdbead92b76de91388b))
* **events:** coerce expires_in_days to Number before addDays ([e5712d8](https://github.com/the-luap/picpeak/commit/e5712d8ffe2f0ed980e1df5e1263876af7202b76))
* **events:** coerce expires_in_days to Number before addDays ([db29d0e](https://github.com/the-luap/picpeak/commit/db29d0e2788f63cc9eb0a43ec58313387acb0c0d))
* **events:** match scrollbar to theme in external folder tree picker ([bd42ee1](https://github.com/the-luap/picpeak/commit/bd42ee1ce03b8f6e7b011b53f2c71453be931cc6))
* **events:** server-side search/pagination to remove first-100 cap ([#346](https://github.com/the-luap/picpeak/issues/346)) ([a5b20ca](https://github.com/the-luap/picpeak/commit/a5b20ca3fe77df665d4a9744413d7ee4054858f0))
* **events:** show customer phone in event details view ([#331](https://github.com/the-luap/picpeak/issues/331)) ([4c73d22](https://github.com/the-luap/picpeak/commit/4c73d228ed98b8ec05bec2824aee7ce066a184e1))
* **events:** stop mapping branding_logo_position onto hero_logo_position ([af2b062](https://github.com/the-luap/picpeak/commit/af2b0628cb4f79a147366665d35c098012071216))
* **events:** stop mapping branding_logo_position onto hero_logo_position ([ef1c875](https://github.com/the-luap/picpeak/commit/ef1c875f6ec1e02657006cb09cd0b1d868ec2fc0))
* external media dimensions, theme race condition, email color customization ([dfae2c2](https://github.com/the-luap/picpeak/commit/dfae2c2bc6d86378c553cd847b439f7cb53a4f2a))
* floor password_changed_at when comparing against JWT iat ([793e410](https://github.com/the-luap/picpeak/commit/793e410554b461522fbe24014dfd3baa915da2bb))
* **fonts:** drop immutable Cache-Control to allow font replacement rollout ([5703fcb](https://github.com/the-luap/picpeak/commit/5703fcb80680155e3b637dd5fc15c430de963c40))
* **gallery:** default controls to inline for every layout ([045e9ea](https://github.com/the-luap/picpeak/commit/045e9ea4861f33e3e82e31f978ac297c7f7824f6))
* **gallery:** lazy-render skeleton grid for fast loads ([#321](https://github.com/the-luap/picpeak/issues/321) follow-up) ([d9d8137](https://github.com/the-luap/picpeak/commit/d9d81372b80f7d44dca54b7993f52c36574048c9))
* **gallery:** preserve sidebar controlsStyle on banner migration ([05dadff](https://github.com/the-luap/picpeak/commit/05dadff4934ad9b055de8875846c2a7175e16f86))
* **gallery:** single-finger swipe nav in mobile lightbox ([#332](https://github.com/the-luap/picpeak/issues/332)) ([4c8eba0](https://github.com/the-luap/picpeak/commit/4c8eba0cb43635d92a53d90c58b19007136c1c12))
* **gallery:** use ref for swipe-start to avoid stale-closure miss ([#332](https://github.com/the-luap/picpeak/issues/332)) ([fcddfe0](https://github.com/the-luap/picpeak/commit/fcddfe094b2a01963f7b420afa886e7d5dae4390))
* **gallery:** WCAG-safe Download button text + extract HeaderDownloadButton ([#401](https://github.com/the-luap/picpeak/issues/401) follow-ups) ([04e928d](https://github.com/the-luap/picpeak/commit/04e928d7621743d9d99797f0996f8c7aa50e7b2d))
* **gallery:** WCAG-safe Download button text + extract HeaderDownloadButton ([#401](https://github.com/the-luap/picpeak/issues/401) follow-ups) ([0c80abd](https://github.com/the-luap/picpeak/commit/0c80abd57b806b9df01429a093c30c12c80c0601))
* guest feedback flow bugs in Masonry grid and PhotoLightbox ([#292](https://github.com/the-luap/picpeak/issues/292)) ([54badef](https://github.com/the-luap/picpeak/commit/54badefc51b834d55530722f87c81a6ade33e35b))
* guest feedback flow bugs in Masonry grid and PhotoLightbox ([#292](https://github.com/the-luap/picpeak/issues/292)) ([77f07e9](https://github.com/the-luap/picpeak/commit/77f07e9329e47f6ac5040f2e85d2710ebbea3ced))
* handle null dates in dashboard and gallery pages ([c5a8ffc](https://github.com/the-luap/picpeak/commit/c5a8ffc08cd4c53c37fe4fb9cde8519a68f1f343))
* hero header state and preview in admin theme editor ([#158](https://github.com/the-luap/picpeak/issues/158)) ([f554f46](https://github.com/the-luap/picpeak/commit/f554f463b3492346dba067c0980b52ef42dd5e70))
* improve ghost button visibility in admin dark mode ([4912e2b](https://github.com/the-luap/picpeak/commit/4912e2bccf282134d5598a8ac80942ed46d0523c))
* improve password validation errors and event list UX ([#170](https://github.com/the-luap/picpeak/issues/170), [#171](https://github.com/the-luap/picpeak/issues/171)) ([171abb3](https://github.com/the-luap/picpeak/commit/171abb31615484d77cf95a99cb5634afa0160adc))
* improve photo serving, category filters, and upload chunking ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156), [#161](https://github.com/the-luap/picpeak/issues/161)) ([fa4c838](https://github.com/the-luap/picpeak/commit/fa4c83812d87cfa63394e51186e320a072929d37))
* increase upload limit to 1GB and fix category filters ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156)) ([397d33a](https://github.com/the-luap/picpeak/commit/397d33a95a09e0b0986c3f6cf5965c544992a764))
* issue [#203](https://github.com/the-luap/picpeak/issues/203) file type validation + security CVE fixes ([8017171](https://github.com/the-luap/picpeak/commit/80171713e0ffedda56f7cffb403b25a8d55634d1))
* **lightbox:** mobile toolbar clipping + iOS safe-area + viewport-fit ([#336](https://github.com/the-luap/picpeak/issues/336)) ([42a7ae4](https://github.com/the-luap/picpeak/commit/42a7ae4be8fe7b12104ae036465c9c4117606378))
* **lightbox:** smooth carousel swipe + drop instructional hint ([#348](https://github.com/the-luap/picpeak/issues/348)) ([743086d](https://github.com/the-luap/picpeak/commit/743086d3cb9100fb163bc9d04d968e5b611a1f99))
* mobile lightbox + share previews + customer phone bug triage ([1e40677](https://github.com/the-luap/picpeak/commit/1e4067713ce9a808a7b49319bc262e5c9a6599c6))
* mobile upload button not visible in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([cacaffa](https://github.com/the-luap/picpeak/commit/cacaffa5c39f67105c4cfb092ea62157121fb72e))
* mobile upload button visibility in gallery ([2a2c23d](https://github.com/the-luap/picpeak/commit/2a2c23d11610e6c81684163eb4ea934a6d6104fb)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
* mobile upload button visibility in gallery ([df7dbff](https://github.com/the-luap/picpeak/commit/df7dbffbffb180e62af0d2b58326f9de0f515439)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([05a5307](https://github.com/the-luap/picpeak/commit/05a5307e22dc45be4b75b2996ff9fac65dec399d))
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([6cb4342](https://github.com/the-luap/picpeak/commit/6cb43428d1e703267edeacda9ede050a8c4f8e0c))
* **nginx:** proxy /fonts requests to backend ([e6c03e4](https://github.com/the-luap/picpeak/commit/e6c03e4b6e4ee2ccc3e3cd8b7a54c18f9685c2ba))
* pin npm to v10 in backend Dockerfile ([ddefd3a](https://github.com/the-luap/picpeak/commit/ddefd3a95e5047d4a22aa4b6fef57dfb1c880967))
* pin npm upgrade to v10 in backend Dockerfile ([978e447](https://github.com/the-luap/picpeak/commit/978e4473b5227ee61ad7d17487063eb3284bea36))
* prevent backend crash on archive when admin_email is null ([#318](https://github.com/the-luap/picpeak/issues/318)) ([e4b0f96](https://github.com/the-luap/picpeak/commit/e4b0f961b75952b6907cc2291fa256215c09c80c))
* prevent database migration restart failures ([83a4344](https://github.com/the-luap/picpeak/commit/83a4344a01de4f65c5024fdf2d177a04457ccd2f)), closes [#107](https://github.com/the-luap/picpeak/issues/107)
* remove non-functional watermark toggle from Feature Toggles ([d4a15db](https://github.com/the-luap/picpeak/commit/d4a15dbe74d0d70bbe6ff03362dc7337fb8f4c5c))
* render minimal/none header styles, cap hero height, switch category hero images ([#158](https://github.com/the-luap/picpeak/issues/158), [#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([bc6c48b](https://github.com/the-luap/picpeak/commit/bc6c48bb2429505c2de3641693a8ff4f623a4951))
* resend gallery email fails for events without password ([6b3ead7](https://github.com/the-luap/picpeak/commit/6b3ead747b1395d8ea2b3d135a5ac24db05e2eb8)), closes [#137](https://github.com/the-luap/picpeak/issues/137)
* resolve admin invitation flow issues and improve STORAGE_PATH documentation ([41bf6ff](https://github.com/the-luap/picpeak/commit/41bf6ff884d5ef3181f95f3aa4a528434c23947a))
* resolve code quality issues and add missing i18n keys ([#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([329d224](https://github.com/the-luap/picpeak/commit/329d224846d3f4eefa31e42337f34047c267d578))
* resolve code scanning security alerts (multer, tar, Node 22) ([85a07fc](https://github.com/the-luap/picpeak/commit/85a07fcca7ad935f4c0c300f5ffe2f3af8da1e5f))
* resolve external media dimensions, gallery theme race condition, and add email color customization ([bbeedd1](https://github.com/the-luap/picpeak/commit/bbeedd1888561b6c57586b5f42bbfee3ffc69fd7))
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([33af088](https://github.com/the-luap/picpeak/commit/33af0885607799e0071e2e74a582c7eb396c9b83))
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([5ea4ef3](https://github.com/the-luap/picpeak/commit/5ea4ef3cf36b06f9e6c9108f80bfe2e9a6470898))
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([33483cf](https://github.com/the-luap/picpeak/commit/33483cf32dfae57f8da51c0765353792239135f9))
* resolve issues [#194](https://github.com/the-luap/picpeak/issues/194), [#195](https://github.com/the-luap/picpeak/issues/195), [#196](https://github.com/the-luap/picpeak/issues/196), [#197](https://github.com/the-luap/picpeak/issues/197) ([cd00bc1](https://github.com/the-luap/picpeak/commit/cd00bc13d4e02a86a0f1742ed1f11f064614b8da))
* resolve JWT iat timing issue in password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([c031b1e](https://github.com/the-luap/picpeak/commit/c031b1e86333d90e8e0e0aa723572efa110f7fd1))
* resolve mixed light/dark mode styling in admin UI ([#175](https://github.com/the-luap/picpeak/issues/175)) ([f8c8abd](https://github.com/the-luap/picpeak/commit/f8c8abd70bbae35d6cd519894624ade33b5115a8))
* resolve password change redirect loop ([#263](https://github.com/the-luap/picpeak/issues/263)) and file watcher crash ([#269](https://github.com/the-luap/picpeak/issues/269)) ([b23c51b](https://github.com/the-luap/picpeak/commit/b23c51b386270dee4d911902b728dfacb1ff1bf9))
* resolve password change redirect loop and file watcher crash ([835bdf5](https://github.com/the-luap/picpeak/commit/835bdf5abb40c7b143c5cdafb507c317a7c349bf)), closes [#269](https://github.com/the-luap/picpeak/issues/269)
* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([07fc5e6](https://github.com/the-luap/picpeak/commit/07fc5e6519cd84f2214479d5f31bc35a495bfe4b))
* resolve redirect loop after mandatory password change ([#263](https://github.com/the-luap/picpeak/issues/263)) ([3c8d344](https://github.com/the-luap/picpeak/commit/3c8d344ddd23974c9cf0f5f63edd6cd07817fee9))
* respect allowed_file_types setting for upload validation ([#203](https://github.com/the-luap/picpeak/issues/203)) ([fe07a14](https://github.com/the-luap/picpeak/commit/fe07a148f1d998c0be00377c1f8b4eca3908305c))
* respect optional email settings in event creation ([831ea6a](https://github.com/the-luap/picpeak/commit/831ea6a3bccfae4ec00ce1f619967b91b85150ce))
* respect optional email settings in event creation ([#217](https://github.com/the-luap/picpeak/issues/217)) ([9c44a0e](https://github.com/the-luap/picpeak/commit/9c44a0ebfa527fa133512eb7f2f03335a2377aaa))
* restore aspect-ratio layouts and improve hero image quality ([#180](https://github.com/the-luap/picpeak/issues/180)) ([3974ba5](https://github.com/the-luap/picpeak/commit/3974ba5de5a6605ad906608d3e4d61620a215059))
* restore aspect-ratio layouts and improve hero image quality ([#180](https://github.com/the-luap/picpeak/issues/180)) ([5cef7fd](https://github.com/the-luap/picpeak/commit/5cef7fdd188389512bc4b55ae61536c8b1219eb8))
* revert /api prefix in adminPhotos.js to avoid double-prefix ([094276d](https://github.com/the-luap/picpeak/commit/094276d3cc7117eee30e4bcbce487e54f0eacb29))
* revert /api prefix in adminPhotos.js to avoid double-prefix ([#307](https://github.com/the-luap/picpeak/issues/307)) ([ceb2a09](https://github.com/the-luap/picpeak/commit/ceb2a09f483b4754fda232c5c1f7acb8971aac10))
* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([85a60a2](https://github.com/the-luap/picpeak/commit/85a60a2dc7526aa6b673e2a04e9fdfba7de7117f))
* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([f362239](https://github.com/the-luap/picpeak/commit/f3622396e77ce5d0b0741e439fc554a1dccaca50))
* **security:** resolve all npm audit vulnerabilities ([4272618](https://github.com/the-luap/picpeak/commit/4272618b3f7fcb06aaca14fb724a6a7733251f24))
* **security:** resolve Docker image CVEs for code scanning alerts ([cbecb93](https://github.com/the-luap/picpeak/commit/cbecb9323cf4b80c800326de14f6df73f60147c1))
* **security:** token invalidation on password change, session timeout enforcement ([0a3a537](https://github.com/the-luap/picpeak/commit/0a3a53763c9f3caef9fdceccf9fdbfdefe9bd8bf))
* **security:** token invalidation on password change, session timeout enforcement ([7ca9631](https://github.com/the-luap/picpeak/commit/7ca96315e254eef58d8ecc505f95a5186d2fa2da))
* set JWT iat after password_changed_at to prevent token rejection ([#263](https://github.com/the-luap/picpeak/issues/263)) ([b1d1667](https://github.com/the-luap/picpeak/commit/b1d16670d56e19f7b35e7f2f12f3611fdb3fab58))
* **share:** OG/Twitter-card metadata for gallery share URLs ([#333](https://github.com/the-luap/picpeak/issues/333)) ([5275621](https://github.com/the-luap/picpeak/commit/5275621fcd38f1ec09b54595163ecd5e63614b1a))
* shorten Save button label on email template editor ([7250c42](https://github.com/the-luap/picpeak/commit/7250c427b905ffa3e8696dff607450f5a0b801b8))
* show upload button in mobile topbar instead of sidebar ([ae181cf](https://github.com/the-luap/picpeak/commit/ae181cf92fc9c1e85cad7a7b843a4d83cec636ac)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
* sync backend package-lock.json for security deps ([bb81fa5](https://github.com/the-luap/picpeak/commit/bb81fa5f4b5f1bd927a02470ce80a13c4f53443f))
* sync backend package-lock.json with security dep updates ([03e1989](https://github.com/the-luap/picpeak/commit/03e19893b3532a27aa59e7b834d53c6a2b52b7cd))
* sync header_style DB column with theme editor selections ([#158](https://github.com/the-luap/picpeak/issues/158)) ([2288309](https://github.com/the-luap/picpeak/commit/228830939553fd32c250704bb89a8ce233324d25))
* sync header_style DB column with theme editor selections ([#158](https://github.com/the-luap/picpeak/issues/158)) ([a19e7c4](https://github.com/the-luap/picpeak/commit/a19e7c40a200ff822c947a83349ed07ccf4e1b01))
* theme picker buttons no longer submit the parent form ([#326](https://github.com/the-luap/picpeak/issues/326)) ([2eead52](https://github.com/the-luap/picpeak/commit/2eead523193ccb7f23eb767097ad9698e8312833))
* theme save without Live Preview, Branding default on new events, gallery loading flicker ([#323](https://github.com/the-luap/picpeak/issues/323), [#321](https://github.com/the-luap/picpeak/issues/321)) ([822be9a](https://github.com/the-luap/picpeak/commit/822be9a9b2716f1832a4cb6fccd53602e3cbab51))
* theme-preset match loop ignores extra fields like logoUrl ([#323](https://github.com/the-luap/picpeak/issues/323)) ([b63a877](https://github.com/the-luap/picpeak/commit/b63a8774c4b44733b903736b2ca5a472a884055e))
* **theme:** centralise force-mode enforcement inside ThemeContext so every gallery flips ([21188f4](https://github.com/the-luap/picpeak/commit/21188f48d76dd29bc1251bcc6faf9d6d96c805b5))
* **theme:** kill initial white frame + theme-aware skeleton tiles ([#358](https://github.com/the-luap/picpeak/issues/358) follow-up) ([f529c9e](https://github.com/the-luap/picpeak/commit/f529c9e3d72f0e3496951dfa5d160afda9a1ac51))
* **theme:** kill initial white frame + theme-aware skeleton tiles ([#358](https://github.com/the-luap/picpeak/issues/358) follow-up) ([1a530ae](https://github.com/the-luap/picpeak/commit/1a530aeaa2d61b34d9721a555b71631c7101c58e))
* **theme:** pre-React bootstrap to kill white-flash on dark galleries ([#358](https://github.com/the-luap/picpeak/issues/358)) ([07b41e6](https://github.com/the-luap/picpeak/commit/07b41e691d2e8a71f775c667d805a2f9adc10590))
* **theme:** pre-React bootstrap to kill white-flash on dark galleries ([#358](https://github.com/the-luap/picpeak/issues/358)) ([f81a872](https://github.com/the-luap/picpeak/commit/f81a8728e67b313ac43f55c94fb635abf9beca05))
* update dependencies to resolve code scanning security alerts ([1f524f2](https://github.com/the-luap/picpeak/commit/1f524f23580d2e2a21dbba28cb46aed76e85c475))
* update docker-compose to docker compose and add ADMIN_PASSWORD to .env.example ([#189](https://github.com/the-luap/picpeak/issues/189)) ([a4c6248](https://github.com/the-luap/picpeak/commit/a4c624802b2926a16adcf0472a3041562f9b2f48))
* update packages to fix security vulnerabilities ([8097a0c](https://github.com/the-luap/picpeak/commit/8097a0cb530bd8003597cde81606231efadb0bf5))
* update security policy with private reporting channels ([308e086](https://github.com/the-luap/picpeak/commit/308e08626383bab213ce3eb5563608dff6168ef4))
* update security policy with private reporting channels ([7f77362](https://github.com/the-luap/picpeak/commit/7f7736282f534adf4b9d5331d841a1f0bff7341c))
* update security policy with proper contact email and private reporting ([67b0f32](https://github.com/the-luap/picpeak/commit/67b0f32456d0216e4c685a104c680fa5a5fd578f)), closes [#223](https://github.com/the-luap/picpeak/issues/223)
* use actual photo aspect ratios in masonry columns mode ([#146](https://github.com/the-luap/picpeak/issues/146)) ([8711f96](https://github.com/the-luap/picpeak/commit/8711f967a15f5d57f6ad01bfdbd8d33f9ee96abc))
* use CSS Columns for gap-free mosaic layout ([#146](https://github.com/the-luap/picpeak/issues/146)) ([821d329](https://github.com/the-luap/picpeak/commit/821d3296ea4b6bde499e5497d258f15ab8dd1dbc))
* use photo dimensions for mosaic aspect ratios ([#146](https://github.com/the-luap/picpeak/issues/146)) ([27ff51e](https://github.com/the-luap/picpeak/commit/27ff51e7a1217848859b47940bc88caa6f1fb20f))
* video upload media type, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([fc75bcd](https://github.com/the-luap/picpeak/commit/fc75bcdfc38673d6e4dd1cd943cfb4638d3a306c))
* video upload, select all, and dimension repair ([#203](https://github.com/the-luap/picpeak/issues/203), [#220](https://github.com/the-luap/picpeak/issues/220), [#180](https://github.com/the-luap/picpeak/issues/180)) ([a0bb080](https://github.com/the-luap/picpeak/commit/a0bb0805868e742f323b64312c3c5ef8ec408f68))
* wire admin photo feedback filters into grid query ([#293](https://github.com/the-luap/picpeak/issues/293)) ([d4b4dc6](https://github.com/the-luap/picpeak/commit/d4b4dc628f28a303ff1c80ba6d8e5e768217ba51))
* wrap email preview with full styled header/footer template ([9a6d2e8](https://github.com/the-luap/picpeak/commit/9a6d2e8e3a3fab8d7969a8a42e94934c38d88392))
* wrap email preview with full styled header/footer template ([fc0911a](https://github.com/the-luap/picpeak/commit/fc0911acf8b7c8a18d71bb4267f1086acd1e0ca1)), closes [#229](https://github.com/the-luap/picpeak/issues/229)
* wrap test email with standard email template ([#252](https://github.com/the-luap/picpeak/issues/252)) ([954a011](https://github.com/the-luap/picpeak/commit/954a0118bae5770c74f1e811e03b8fc702c70db2))
### Bug Fixes
### Reverts
* **external-media:** pre-generate thumbnails so reference-mode galleries load fast ([#423](https://github.com/the-luap/picpeak/issues/423)) ([e2ffd9f](https://github.com/the-luap/picpeak/commit/e2ffd9f93d228f9e16408fe424c65b26db67ac8e))
* **external-media:** pre-generate thumbnails so reference-mode galleries load fast ([#423](https://github.com/the-luap/picpeak/issues/423)) ([f3d0f16](https://github.com/the-luap/picpeak/commit/f3d0f161c9e554a5149e6b4eafdb0ac42bebf277))
## [3.42.5-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.4-beta.0...v3.42.5-beta.0) (2026-05-08)
* **branding:** per-option font preview (defer to follow-up) ([f410207](https://github.com/the-luap/picpeak/commit/f410207b2d7ddf1c9525603c7dcbb7cdfee1729b))
### Bug Fixes
### Documentation
* **admin:** test email always sends, regardless of update availability ([#418](https://github.com/the-luap/picpeak/issues/418)) ([9326a42](https://github.com/the-luap/picpeak/commit/9326a427b32458dfdaa01530bac66cda84ed7b72))
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
* add Buy Me a Coffee badge + Support section ([46bc894](https://github.com/the-luap/picpeak/commit/46bc894d917bd55dbd9bafaa64fd38db21488b81))
* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([2e1c71c](https://github.com/the-luap/picpeak/commit/2e1c71c1ab073e488ac93e35337a2d3955dfef3d))
* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([f6ca713](https://github.com/the-luap/picpeak/commit/f6ca713a6edc8ba371db790daba05ecb85ea4872))
* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([5295516](https://github.com/the-luap/picpeak/commit/5295516b67a1d9f035564c5f9a724f25f8d21c78))
* clarify file system photo import requires existing event ([#269](https://github.com/the-luap/picpeak/issues/269)) ([ee0baaf](https://github.com/the-luap/picpeak/commit/ee0baafc59f3588a26172aa8835c12dcaec35d10))
* emphasize importance of STORAGE_PATH in env example ([3397807](https://github.com/the-luap/picpeak/commit/3397807670784e02cbe34a7a60db43c95d64f19c))
* **fonts:** cache rollout, stale-list note, meta.json ([bd0e052](https://github.com/the-luap/picpeak/commit/bd0e052b1a1847718151a16117dacc6c42a2178e))
* move documentation to docs.picpeak.app, drop in-repo copies ([02ed5d4](https://github.com/the-luap/picpeak/commit/02ed5d400736f966283a138dedde2455448067ff))
* move documentation to docs.picpeak.app, drop in-repo copies ([0faf9b3](https://github.com/the-luap/picpeak/commit/0faf9b32816f5f94aa584d2336cdb1e0b7082239))
* **readme:** add Contributors section with @Luca-Timo and @Rekoo-PS ([c60ab74](https://github.com/the-luap/picpeak/commit/c60ab74ae2daabc4b11fea1f1b2df728294b03c8))
* **readme:** add Contributors section with @Luca-Timo and @Rekoo-PS ([dbe0a30](https://github.com/the-luap/picpeak/commit/dbe0a3055bd2c71981cb7d9cf43c2b22b9e3276c))
* rewrite README — shorter, cleaner ([62643f2](https://github.com/the-luap/picpeak/commit/62643f241b51dc1620e30a8c8767f52428c0314c))
* rewrite README — shorter, cleaner, less AI-sounding ([64f6061](https://github.com/the-luap/picpeak/commit/64f606152fde2db9034fa9ffa08cc58623edf646))
## [3.42.4-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.3-beta.0...v3.42.4-beta.0) (2026-05-08)
## [3.42.1](https://github.com/the-luap/picpeak/compare/v2.6.5...v3.42.1) (2026-05-07)
Stable release promoting the entire `beta` channel to `main`. Brings ~300 commits of features, fixes, and infrastructure improvements that have been baked on the beta channel since v2.6.5. Highlights below; full per-version notes follow in the beta history.
### Bug Fixes
### Major themes since v2.6.5
* **events:** typed-DELETE confirmation for bulk delete ([#417](https://github.com/the-luap/picpeak/issues/417)) ([e165ee5](https://github.com/the-luap/picpeak/commit/e165ee5d9fa805c704a64f91c9514bf0ab75b5b8))
* **events:** typed-DELETE confirmation for bulk delete ([#417](https://github.com/the-luap/picpeak/issues/417)) ([99e420b](https://github.com/the-luap/picpeak/commit/99e420b1b9783a1d6b4eb892c09d0af3340bf314))
* **Multi-administrator support with RBAC** — super admin / admin / editor roles, fine-grained permissions
* **Async upload pipeline** — bytes-on-wire returns 202; sharp/ffmpeg/EXIF/watermark/webhooks happen in a background worker pool
* **Self-hosted webfonts** — filesystem-driven scanner, GDPR-compliant, replaces Google Fonts CDN
* **8-token CI palette + force color mode** — full theme customization across admin and public site
* **Native multi-arch Docker images** — Apple Silicon and ARM64 Linux supported natively
* **Native S3 storage backend** — S3 + S3-compatible providers
* **Comprehensive video support** — upload, stream, and play MP4/WebM/MOV alongside photos
* **Outbound webhooks** — event/photo lifecycle push API with HMAC signatures
* **Gallery layout system** — decoupled header style from layout, banner option, theme-aware skeletons, and lazy-loaded folder picker
* **Multilingual email templates** — translations table for EN/DE/NL/PT/RU
* **Bulk operations** — bulk delete with password confirmation, bulk archive
* **Photo dimensions backfill** — true masonry layout with portrait/landscape sizing
* **Customer client access** — separate review/visibility area before the gallery is shared with guests
* **Image security** — devtools detection, watermarking, right-click prevention, secure thumbnails
## [3.42.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.2-beta.0...v3.42.3-beta.0) (2026-05-07)
### Bug fixes (highlights from beta)
### Bug Fixes
* **create-event:** re-apply Branding theme on stale→fresh settings ([#323](https://github.com/the-luap/picpeak/issues/323)-B) ([401abf7](https://github.com/the-luap/picpeak/commit/401abf7a27cb73dd7fb8399f4c05644c95767093))
* **security:** scan triage cleanup — drop dead deps, harden Docker/nginx/postMessage ([7abfeb9](https://github.com/the-luap/picpeak/commit/7abfeb91cc7bbb9b6853146dbfe16b8d9835bcb3))
* **security:** scan triage cleanup — drop dead deps, harden Docker/nginx/postMessage ([6b6191a](https://github.com/the-luap/picpeak/commit/6b6191a4260650e21c45f6153cac1b142bf8483a))
## [3.42.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.1-beta.0...v3.42.2-beta.0) (2026-05-07)
### Bug Fixes
* **security:** patch 18 dependency CVEs (axios + transitives + nodemailer + i18next-http-backend) ([b7d6ca0](https://github.com/the-luap/picpeak/commit/b7d6ca0b65e652b50d957380f93ed16e151e94a8))
* **security:** patch 18 dependency CVEs (axios + transitives) ([523f499](https://github.com/the-luap/picpeak/commit/523f49916bea44697d40f61e0b6e44b83decc4b9))
* `/auth/session` symmetry fixes for the admin-login redirect-loop family (#350, #355, #363, #398)
* Email template renderer: handle `{{#if}}` conditionals, fix CSS leak in plain-text fallback, gate publish-from-draft password placeholder, gate `external_url` in public response
* Customer email caller/template variable drift across gallery_created, expiration_warning, archive_complete, gallery_expired
* Full-URL `gallery_link` in all email types (was path-only in 3 sites)
* ffmpeg/ffprobe installed via apk for Alpine compatibility (was glibc-bundled binary)
* Theme-aware skeleton tiles, dark theme white-flash on first paint
* Admin events search and counters not bounded to first 100 records (#346)
* Login redirect loop with stale admin cookies (#350) — three rounds of asymmetry fixes
## [3.42.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.42.0-beta.0...v3.42.1-beta.0) (2026-05-07)
@@ -1892,3 +2119,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [1.1.15] - Previous Release
Initial stable release with core functionality.
---
# Pre-3.x history (main 2.x channel)
## [2.6.5](https://github.com/the-luap/picpeak/compare/v2.6.4...v2.6.5) (2026-04-08)
### Documentation
* rewrite README — shorter, cleaner ([62643f2](https://github.com/the-luap/picpeak/commit/62643f241b51dc1620e30a8c8767f52428c0314c))
* rewrite README — shorter, cleaner, less AI-sounding ([64f6061](https://github.com/the-luap/picpeak/commit/64f606152fde2db9034fa9ffa08cc58623edf646))
## [2.6.4](https://github.com/the-luap/picpeak/compare/v2.6.3...v2.6.4) (2026-04-08)
### Bug Fixes
* sync backend package-lock.json for security deps ([bb81fa5](https://github.com/the-luap/picpeak/commit/bb81fa5f4b5f1bd927a02470ce80a13c4f53443f))
* sync backend package-lock.json with security dep updates ([03e1989](https://github.com/the-luap/picpeak/commit/03e19893b3532a27aa59e7b834d53c6a2b52b7cd))
## [2.6.3](https://github.com/the-luap/picpeak/compare/v2.6.2...v2.6.3) (2026-04-07)
### Documentation
* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([2e1c71c](https://github.com/the-luap/picpeak/commit/2e1c71c1ab073e488ac93e35337a2d3955dfef3d))
* add External Media Library section to deployment guide ([#270](https://github.com/the-luap/picpeak/issues/270)) ([f6ca713](https://github.com/the-luap/picpeak/commit/f6ca713a6edc8ba371db790daba05ecb85ea4872))
## [2.6.2](https://github.com/the-luap/picpeak/compare/v2.6.1...v2.6.2) (2026-03-16)
### Bug Fixes
* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([85a60a2](https://github.com/the-luap/picpeak/commit/85a60a2dc7526aa6b673e2a04e9fdfba7de7117f))
* **security:** token invalidation on password change, session timeout enforcement ([0a3a537](https://github.com/the-luap/picpeak/commit/0a3a53763c9f3caef9fdceccf9fdbfdefe9bd8bf))
## [2.6.1](https://github.com/the-luap/picpeak/compare/v2.6.0...v2.6.1) (2026-03-11)
+888
View File
@@ -0,0 +1,888 @@
# 🚀 PicPeak Deployment Guide
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
## 📋 Table of Contents
- [Quick Start](#-quick-start)
- [Prerequisites](#prerequisites)
- [Configuration](#-configuration)
- [Deployment](#-deployment)
- [First Login](#-first-login)
- [Release Channels](#-release-channels)
- [Reverse Proxy Setup](#-reverse-proxy-setup)
- [External Media Library](#-external-media-library)
- [Maintenance](#-maintenance)
- [Troubleshooting](#-troubleshooting)
## 🚀 Quick Start
### Option 1: Automated Setup Script (Easiest)
For the simplest installation, use our unified setup script:
```bash
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
This script handles Docker/Native installation choice, OS detection, dependencies, database setup, and optional SSL.
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
### Option 2: Docker with Pre-built Images (Recommended)
```bash
# Clone repository for configuration files
git clone https://github.com/the-luap/picpeak.git
cd picpeak
# Copy and configure environment
cp .env.example .env
nano .env # Edit with your values
# Create required directories
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
# Deploy using pre-built images
docker compose -f docker-compose.production.yml up -d
# Check logs
docker compose -f docker-compose.production.yml logs -f
```
**Available image tags:**
| Channel | Tags | Description |
|---------|------|-------------|
| Stable | `stable`, `latest`, `v2.3.0` | Production-ready releases |
| Beta | `beta`, `v2.3.0-beta.1` | Early access to new features |
| Branch | `main`, `beta` | Latest from each branch |
To select a channel, set `PICPEAK_CHANNEL` in your `.env` file (see [Release Channels](#release-channels) section)
### Option 3: Build from Source
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
cp .env.example .env
nano .env # Edit with your values
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
docker compose build
docker compose up -d
```
## Prerequisites
- Docker and Docker Compose installed
- Domain name (for production)
- SMTP server credentials for emails
- At least 2GB RAM and 20GB storage
## 🔧 Configuration
### Essential Environment Variables
Generate secure values:
```bash
# JWT Secret
openssl rand -base64 64
# Database Password (avoid $ character - see warning below)
openssl rand -base64 32 | tr -d '$'
# Redis Password (avoid $ character - see warning below)
openssl rand -base64 32 | tr -d '$'
```
⚠️ **PASSWORD WARNING**: Docker Compose interprets `$` as variable substitution. Either:
- Avoid `$` in passwords (recommended - use the commands above)
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
### Public Landing Page
- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
### Backend Configuration (.env)
Update `.env` with:
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
- `DB_PASSWORD` - PostgreSQL password
- `REDIS_PASSWORD` - Redis password
- `SMTP_*` - Email configuration
- **URL Configuration** (for backend CORS):
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
- Example (Docker): `http://localhost:3000`
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
- Example (Docker): `http://localhost:3000`
Notes:
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
- Always include the scheme (`http://` or `https://`).
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
#### Authentication Security
- Configure login attempt thresholds from **Admin → Settings → Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout.
#### External Database Example
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
```env
DB_HOST=db.example.com
DB_PORT=5432
DB_USER=picpeak
DB_PASSWORD=change_me
DB_NAME=picpeak_prod
```
Compose uses these values via `env_file: .env`. The backend service also defaults `DB_HOST=${DB_HOST:-postgres}` so if you dont set `DB_HOST` it will use the bundled `postgres` container.
### Frontend Configuration (frontend/.env)
Create `frontend/.env` from `frontend/.env.example`:
```bash
cp frontend/.env.example frontend/.env
```
Update `frontend/.env` with:
- `VITE_API_URL` - Backend API URL
- Docker (pre-built images) and production behind reverse proxy: `/api` (recommended; avoids CORS and matches the frontend Nginx proxy in the image)
- Local dev (Vite): `http://localhost:3001` or `/api` if proxying through a dev proxy
Note: When using pre-built frontend images, runtime container env does not change the already-built JS. Prefer the default `/api` and let the frontend Nginx proxy forward to the backend.
⚠️ **IMPORTANT PORT CONFIGURATION**:
- The frontend runs on port **3000** in Docker (exposed via nginx)
- The backend API runs on port **3001**
- The frontend `.env` file MUST point to the correct backend port (3001)
- Default `.env.example` is configured for Docker deployment
### Email Configuration Examples
#### Gmail
```env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-specific-password
```
#### SendGrid
```env
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=apikey
SMTP_PASS=your-sendgrid-api-key
```
## 📦 Deployment
### Using Pre-built Images (Fastest)
```bash
# Pull latest images from GitHub Container Registry
docker pull ghcr.io/the-luap/picpeak/backend:latest
docker pull ghcr.io/the-luap/picpeak/frontend:latest
# Start services using production compose file
docker compose -f docker-compose.production.yml up -d
# View running containers
docker compose ps
```
### Building from Source (For Customization)
```bash
# Build images locally
docker compose build
# Or build with no cache for clean build
docker compose build --no-cache
# Start all services
docker compose up -d
# View running containers
docker compose ps
```
### Access Points
By default, services are exposed on:
- Frontend (UI + Admin): http://localhost:3000 (admin at `/admin`)
- Backend/API: http://localhost:3001 (API only; no UI routes)
- PostgreSQL: localhost:5432 (if needed)
- Redis: localhost:6379 (if needed)
### Initial Admin Setup
When deploying for the first time, an admin account is automatically created with a secure, randomly generated password. This password is displayed in the Docker logs during initialization and **must be changed** on first login.
#### Finding the Auto-Generated Admin Password
The admin password is automatically generated during the first startup and displayed in the backend container logs. Here's how to find it:
**Option 1: Search Docker logs for admin password** (recommended)
```bash
# Find the auto-generated admin password in logs
docker compose logs backend | grep "Admin password"
```
You should see output like:
```
✅ Admin password generated: BraveTiger6231!
```
**Option 2: View the complete initialization logs**
```bash
# View the complete admin setup logs
docker compose logs backend | grep -A 10 "Admin user created"
```
**Option 3: Check the saved credentials file**
```bash
# The password is also saved in the backend container
docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt
```
**Option 4: Use the helper script**
```bash
# Show current admin username and email (password is hidden)
docker exec picpeak-backend node scripts/show-admin-credentials.js
# Reset the admin password to a new random password (displays new password in console)
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
```
> **Note:** When using `--reset`, the new password will be displayed in the console output. Save it immediately - it will not be shown again!
#### Important Security Notes
- **Login requires the email address**, not username
- When resetting password, the new password is displayed once in the console - save it immediately
- **Password change is MANDATORY** on first login - the system will force you to change it
- If you lose the password before first login, use the `--reset` option to generate a new one
- New password requirements: minimum 12 characters, mixed case, numbers, and special characters
## 🔐 First Login
After deployment, you must complete the first login process which includes mandatory password change for security.
### Step 1: Locate Your Admin Password
1. **Find the auto-generated password** from the credentials file:
```bash
# Docker deployment
docker compose exec backend cat /app/data/ADMIN_CREDENTIALS.txt
# Or directly from the host (if you have access)
cat data/ADMIN_CREDENTIALS.txt
```
2. **Note the admin email** (default: `admin@example.com` unless customized)
### Step 2: Access Admin Panel
1. Navigate to your frontend domain and open the admin section:
- `http://your-domain.com/admin` (behind reverse proxy)
- `http://localhost:3000/admin` (Docker local)
The backend at `:3001` serves API only and does not serve the admin UI.
2. Login using:
- **Email**: `admin@example.com` (or your custom admin email)
- **Password**: The auto-generated password from the logs
### Step 3: Mandatory Password Change
Upon first login, the system will **automatically redirect** you to change your password:
1. **You cannot skip this step** - it's enforced for security
2. Enter the current auto-generated password
3. Create a new secure password meeting these requirements:
- Minimum 12 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one number
- At least one special character (!@#$%^&*)
### Security Best Practices for New Password
- **Use a unique password** not used elsewhere
- **Consider a password manager** for generation and storage
- **Include mixed characters**: `MySecureP@ssw0rd2024!`
- **Avoid personal information** (names, dates, etc.)
- **Save securely** - you cannot recover this password easily
### If You Lose Access
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
```bash
# Native reinstall example
sudo ./picpeak-setup.sh --native --force-admin-password-reset
# Docker reinstall example
sudo ./picpeak-setup.sh --docker --force-admin-password-reset
```
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
#### Configuring Admin Email
By default, the admin email is `admin@example.com`. To use a different email address, set it in your `.env` file before first deployment:
```env
# .env
ADMIN_EMAIL=your-email@yourdomain.com
```
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
## 🔄 Release Channels
PicPeak offers two release channels for different needs:
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
### Configuring Your Channel
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
```bash
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
The `docker-compose.production.yml` uses this variable for both backend and frontend images:
```yaml
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
```
### Switching Channels
To switch between channels:
```bash
# Edit your .env file
nano .env
# Change PICPEAK_CHANNEL=stable to PICPEAK_CHANNEL=beta (or vice versa)
# Pull the new images and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
### Update Notifications
The admin dashboard automatically notifies you when updates are available for your channel. This feature:
- Checks GitHub releases hourly (cached to avoid rate limits)
- Shows updates relevant to your current channel (stable or beta)
- Can be disabled by setting `UPDATE_CHECK_ENABLED=false` in your `.env`
## 🔒 Reverse Proxy Setup
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
### Routing Schema
PicPeak consists of two services that need to be routed correctly:
| Path | Service | Port | Description |
|------|---------|------|-------------|
| `/api/*` | Backend | 3001 | All API endpoints |
| `/photos/*` | Backend | 3001 | Protected photo files |
| `/thumbnails/*` | Backend | 3001 | Protected thumbnail files |
| `/uploads/*` | Backend | 3001 | Upload files |
| `/*` (everything else) | Frontend | 3000 | React SPA (including `/admin/*`, `/gallery/*`) |
> **Important:** The `/admin/*` routes are served by the frontend (React SPA), NOT the backend. The backend only handles `/api/admin/*` requests.
### Option 1: Nginx
Install nginx and create `/etc/nginx/sites-available/picpeak`:
```nginx
server {
listen 80;
server_name your-domain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# Backend: API endpoints
location /api/ {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend: Protected media files
location ~ ^/(photos|thumbnails|uploads)/ {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend: Everything else (React SPA)
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Enable the site:
```bash
sudo ln -s /etc/nginx/sites-available/picpeak /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### Option 2: Traefik
Add labels to `docker-compose.override.yml`:
```yaml
version: '3.8'
services:
frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak.rule=Host(`your-domain.com`)"
- "traefik.http.routers.picpeak.entrypoints=websecure"
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
backend:
labels:
- "traefik.enable=true"
# API endpoints
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001"
# Protected media files
- "traefik.http.routers.picpeak-media.rule=Host(`your-domain.com`) && (PathPrefix(`/photos`) || PathPrefix(`/thumbnails`) || PathPrefix(`/uploads`))"
- "traefik.http.routers.picpeak-media.entrypoints=websecure"
- "traefik.http.routers.picpeak-media.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-media.loadbalancer.server.port=3001"
```
### Option 3: Caddy
Create a `Caddyfile`:
```caddyfile
your-domain.com {
# Backend: API endpoints
handle /api/* {
reverse_proxy localhost:3001
}
# Backend: Protected media files
handle /photos/* {
reverse_proxy localhost:3001
}
handle /thumbnails/* {
reverse_proxy localhost:3001
}
handle /uploads/* {
reverse_proxy localhost:3001
}
# Frontend: Everything else (React SPA including /admin/*, /gallery/*)
handle {
reverse_proxy localhost:3000
}
}
```
### SSL Certificates
For any reverse proxy, you can use Let's Encrypt:
```bash
# With Certbot
sudo certbot certonly --webroot -w /var/www/certbot -d your-domain.com
# Or use your reverse proxy's built-in ACME support
```
## 📂 External Media Library
The External Media Library allows events to reference photos stored directly on your host filesystem instead of uploading them through the admin UI. This is useful for photographers who already have organized photo libraries and want to share them without re-uploading.
### How It Works
- **Managed mode** (default): Photos are uploaded through the admin UI and stored inside PicPeak's storage directory.
- **Reference mode**: Photos remain on your host filesystem. PicPeak reads them directly and generates thumbnails on demand.
Each event can use either mode. Reference mode events point to a folder under the configured external media root.
### Configuration
Add the following to your `.env` file:
```bash
# Path where your photo library is stored on the host
EXTERNAL_MEDIA_ROOT=/path/to/your/photos
```
Then mount this path into the backend container in your `docker-compose.yml` or `docker-compose.production.yml`:
```yaml
services:
backend:
environment:
- EXTERNAL_MEDIA_ROOT=/external-media
volumes:
- /path/to/your/photos:/external-media:ro # read-only is recommended
```
> **Permissions**: Ensure the container user (`PUID`/`PGID`) has read access to the mounted directory. If thumbnails fail to generate, this is usually a permissions issue.
### Folder Structure
Organize your photos with subdirectories for each event. Within each event folder, use `individual/` and `collages/` subdirectories to classify photos:
```
/path/to/your/photos/
├── wedding-smith-2026/
│ ├── individual/
│ │ ├── IMG_0001.jpg
│ │ ├── IMG_0002.jpg
│ │ └── ...
│ └── collages/
│ ├── group-photo.jpg
│ └── ...
├── corporate-event/
│ ├── individual/
│ │ └── ...
│ └── collages/
│ └── ...
```
Supported file formats: `.jpg`, `.jpeg`, `.png`, `.webp`
### Usage
1. **Create an event** in the admin panel as usual (name, date, email, etc.).
2. **Switch source mode** to "Reference external folder" in the event details under Source Mode.
3. **Browse and select** the external folder using the folder picker that appears. Navigate to the event's directory.
4. **Import photos** by clicking "Import from External Folder" in the Photos tab. PicPeak will:
- Recursively scan the selected folder
- Classify photos by subfolder name (`individual/` or `collages/`)
- Deduplicate by filename (keeps the largest file if duplicates exist)
- Extract image dimensions for gallery layout
- Register the photos in the database
5. **Thumbnails** are generated on demand when a guest first views the gallery. There is no upfront processing delay.
### Limitations
- **Images only** — video files are not supported for external media.
- **Read-only** — PicPeak does not modify or delete files in the external media directory.
- **No automatic sync** — If you add new photos to the external folder, you need to re-import from the admin UI.
- **Backup caveat** — External media originals are excluded from PicPeak's built-in backup system. Only thumbnails and database records are backed up. You are responsible for backing up the source files separately.
### Troubleshooting
| Problem | Solution |
|---------|----------|
| Folder picker shows empty directory | Check that the volume is mounted correctly and `EXTERNAL_MEDIA_ROOT` matches the container path |
| "Permission denied" errors | Ensure `PUID`/`PGID` in `.env` match the owner of the external media files on the host |
| Thumbnails not generating | Verify the backend container can read the files: `docker exec picpeak-backend ls /external-media/your-folder/` |
| Import finds 0 photos | Only `.jpg`, `.jpeg`, `.png`, `.webp` files are supported. Check file extensions. |
## 🔧 Maintenance
### Viewing Logs
```bash
# All services
docker compose logs -f
# Specific service
docker compose logs -f backend
docker compose logs -f frontend
```
### Backup
#### Manual Backup
```bash
# Database backup
docker exec picpeak-postgres pg_dump -U picpeak picpeak_prod > backup/db_$(date +%Y%m%d_%H%M%S).sql
# Files backup
tar -czf backup/photos_$(date +%Y%m%d_%H%M%S).tar.gz events/
```
#### Automated Backup
The application includes a built-in backup service. Configure it in the admin panel:
1. Login to admin panel
2. Go to Settings → Backup
3. Configure destination and schedule
4. Enable backup service
### Updates
#### Method 1: Using Pre-built Images (Recommended)
```bash
# Pull latest changes (for configuration updates)
git pull
# Pull latest images from GitHub Container Registry
docker compose -f docker-compose.production.yml pull
# Restart with new images
docker compose -f docker-compose.production.yml down
docker compose -f docker-compose.production.yml up -d
# Verify services are healthy
docker compose -f docker-compose.production.yml ps
```
#### Method 2: Building from Source
```bash
# Pull latest changes
git pull
# Rebuild and restart
docker compose down
docker compose build --no-cache
docker compose up -d
# Verify services are healthy
docker compose ps
```
#### Specific Version or Channel Updates
To use a specific version or switch channels, update your `.env` file:
```bash
# Edit .env to change the channel or pin to a specific version
nano .env
# Options for PICPEAK_CHANNEL:
# - stable (recommended, production-ready)
# - beta (early access to new features)
# - v2.3.0 (pin to specific stable version)
# - v2.3.0-beta.1 (pin to specific beta version)
# Then pull and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
The admin dashboard will notify you when updates are available for your configured channel.
### Database Migrations
Migrations run automatically on startup, but you can run them manually:
```bash
docker exec picpeak-backend npm run migrate
```
## 🚨 Troubleshooting
### Common Issues
#### 502 Bad Gateway / Login Failures
**This is the most common deployment issue!** Usually caused by misconfigured URLs or network problems:
1. **CORS Configuration Errors**:
```bash
# WRONG - Missing port will cause CORS errors
FRONTEND_URL=http://10.0.252.12
# CORRECT - Include the port you're accessing from
FRONTEND_URL=http://10.0.252.12:3000
```
The backend validates Origin headers against `FRONTEND_URL` for CORS. If they don't match exactly, you'll get 500 errors on login.
2. **After Container Restarts**:
- Nginx may have cached old container IPs
- Solution: `docker restart picpeak-frontend`
- Always wait 30-60 seconds for health checks
3. **Backend Not Starting After Migrations**:
- The logs may only show migrations completed
- Check if server is actually running: `docker exec picpeak-backend ps aux | grep node`
- Should see `node server.js` process
4. **Login After Fresh Install**:
- Check backend logs for auto-generated admin password: `docker compose logs backend | grep "Admin password"`
- Email: `admin@example.com` (or your custom admin email from .env)
- Password: Auto-generated and shown in logs (e.g., `BraveTiger6231!`)
- Remember: Password MUST be changed on first login
5. **Complete Fix Sequence**:
```bash
# 1. Fix your .env file URLs
# 2. Full restart
docker-compose down
docker-compose up -d
# 3. Wait for healthy status
sleep 60
docker ps # All should show (healthy)
# 4. Test backend directly
curl http://localhost:3001/health
# 5. Test through frontend
curl http://localhost:3000/api/public/settings
```
#### Port Already in Use
```bash
# Check what's using the port
sudo lsof -i :3000
sudo lsof -i :3001
# Change ports in .env
FRONTEND_PORT=3002
BACKEND_PORT=3003
```
#### Docker Compose Variable Substitution Errors
If you see warnings like:
```
WARN[0000] The "fgbf" variable is not set. Defaulting to a blank string.
```
This means your password contains `$` which Docker Compose interprets as a variable. Solutions:
1. **Best**: Generate passwords without `$`: `openssl rand -base64 32 | tr -d '$'`
2. **Alternative**: Escape `$` as `$$` in your .env file
3. **Example**: `DB_PASSWORD=Pass@#$$fgbf` instead of `DB_PASSWORD=Pass@#$fgbf`
#### Permission Errors
```bash
# Fix ownership
sudo chown -R 1000:1000 events data logs backup storage
chmod -R 755 events data logs backup storage
```
#### Database Connection Issues
```bash
# Check if database is running
docker compose ps
docker compose logs postgres
# Test connection
docker exec picpeak-postgres pg_isready
```
#### Email Not Sending
- Verify SMTP settings in .env
- Check email queue: `docker exec picpeak-backend psql -U picpeak -d picpeak_prod -c "SELECT * FROM email_queue ORDER BY created_at DESC LIMIT 10;"`
- For Gmail, use app-specific password
- Check logs: `docker compose logs backend | grep email`
### Health Checks
```bash
# Backend health
curl http://localhost:3001/api/health
# Frontend health
curl http://localhost:3000
# Database health
docker exec picpeak-postgres pg_isready
```
### Useful Commands
```bash
# Enter backend container
docker exec -it picpeak-backend sh
# Enter database
docker exec -it picpeak-postgres psql -U picpeak picpeak_prod
# Reset admin password
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
# Check disk usage
df -h
du -sh events/ storage/ backup/
# View running processes
docker compose top
```
## Security Recommendations
1. **Use HTTPS**: Always use a reverse proxy with SSL in production
2. **Firewall**: Only expose necessary ports (80, 443)
3. **Secure passwords**: Use strong, unique passwords for all services
4. **Regular updates**: Keep Docker images and system packages updated
5. **Backup strategy**: Set up automated backups and test restoration
6. **Monitor logs**: Regularly check logs for suspicious activity
7. **Rate limiting**: The app includes built-in rate limiting, configure as needed
## Support
For issues and questions:
- Check logs first: `docker compose logs`
- Review documentation in the repository
- Check existing issues on GitHub
- Create a new issue with:
- Error messages
- Log output
- Environment details (without secrets)
- Steps to reproduce
+89 -419
View File
@@ -1,482 +1,152 @@
# 📸 PicPeak - Open Source Photo Sharing for Events
# PicPeak
<div align="center">
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
**Self-hosted photo sharing for event photographers.**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/)
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/)
[![Buy Me A Coffee](https://img.shields.io/badge/Buy%20Me%20a%20Coffee-theluap-FFDD00?logo=buymeacoffee&logoColor=black)](https://buymeacoffee.com/theluap)
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](https://docs.picpeak.app) · [Support the project ☕](https://buymeacoffee.com/theluap)
[Live Demo](https://demo.picpeak.app) · [Deployment Guide](DEPLOYMENT_GUIDE.md) · [Homepage](https://www.picpeak.app)
</div>
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
---
PicPeak lets you create password-protected, time-limited photo galleries for your clients — hosted on your own server. No subscriptions, no storage limits, no third-party access to your photos.
![PicPeak Gallery Preview](docs/screenshot-gallery.png)
## 🎮 Live Demo
## Demo
Try PicPeak without installing anything:
Try it out at [demo.picpeak.app](https://demo.picpeak.app).
| | |
|---|---|
| **Demo URL** | [demo.picpeak.app](https://demo.picpeak.app) |
| **Admin Panel** | [demo.picpeak.app/admin](https://demo.picpeak.app/admin) |
| **Email** | `demo@picpeak.app` |
| **Password** | `Demo2026!` |
Admin panel: [demo.picpeak.app/admin](https://demo.picpeak.app/admin) — login with `demo@picpeak.app` / `Demo2026!`
> The demo resets periodically. Uploaded content may be removed without notice.
> The demo resets periodically.
## 🌟 Why Choose PicPeak?
## Features
Unlike expensive SaaS solutions, PicPeak gives you:
**Gallery Management** — Create galleries, upload photos via drag & drop, set passwords and expiration dates. Galleries auto-archive when they expire. Events start as drafts so you can upload and prepare before notifying the client.
- **💰 No Monthly Fees** - One-time setup, unlimited galleries
- **🔒 Complete Data Control** - Your photos stay on your server
- **🎨 White-Label Ready** - Full branding customization
- **📱 Mobile-First Design** - Beautiful on all devices
- **🚀 Lightning Fast** - Optimized performance and caching
- **🌍 Multi-Language** - Built-in i18n support (EN, DE)
**Client Experience** — Responsive galleries that look great on any device. Guests can browse, download individual photos or everything at once. Optional guest uploads and feedback (likes, comments, ratings).
## ✨ Key Features
**Themes & Branding** — 11 built-in theme presets, custom CSS templates, configurable colors/fonts/layouts. White-label your admin panel and login page with your own logo and company name.
### For Photographers
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
- 🔗 **External Media (Reference Mode)** - Browse and import from a readonly external folder library without copying originals
-**Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
- 🔐 **Password Protection** - Secure client galleries
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
- 🎨 **Custom Themes** - Match your brand perfectly
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
**Email Notifications** — Automated gallery creation, expiration warning, and archive emails. Multilingual templates (EN, DE, NL, PT, RU) editable from the admin UI.
### For Clients
- 🖼️ **Beautiful Galleries** - Clean, modern interface
- 📱 **Mobile Optimized** - Swipe through photos on any device
- ⬇️ **Bulk Downloads** - Download all photos with one click
- 🔍 **Smart Search** - Find photos quickly
- 📤 **Guest Uploads** - Optional client photo uploads
- 🛡️ **Download Protection** - Advanced image protection with watermarking and right-click prevention
**Photo Protection** — Watermarking, right-click prevention, canvas rendering, DevTools detection. Configurable per gallery.
### Technical Excellence
- 🐳 **Docker Ready** - Deploy in minutes
- 🔄 **Auto-Processing** - Automatic thumbnail generation
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
- 💾 **Smart Storage** - Automatic archiving of expired galleries
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
- 📈 **Scalable** - From small studios to large agencies
**External Media** — Reference photos from a mounted folder instead of uploading. PicPeak reads originals in place and generates thumbnails on demand.
## 🚀 Quick Start
**Multi-Language** — Full UI translations for English, German, Dutch, Portuguese, and Russian. Email templates support all languages independently.
Get PicPeak running in under 5 minutes:
**Analytics** — Built-in view/download tracking plus optional Umami integration for privacy-focused analytics.
**Video Support** — Upload and stream MP4, WebM, MOV alongside photos. FFmpeg bundled via npm.
**Multiple Admins** — Role-based access control with super admin, admin, and editor roles.
## Quick Start
```bash
# Clone the repository
git clone https://github.com/the-luap/picpeak.git
cd picpeak
# Copy environment template
cp .env.example .env
# Edit configuration (required: JWT_SECRET)
nano .env
# Start with Docker Compose
# Edit .env — set at least JWT_SECRET and passwords
docker compose up -d
# Access at http://localhost:3000
```
Note on Docker file permissions (PUID/PGID)
- When using bind mounts (e.g., `./storage`, `./data`, `./logs`, `./events`), ensure the container user can write to these host folders. The backend runs as a nonroot user by default.
- Set `PUID` and `PGID` in your `.env` to match your host users UID/GID (run `id -u` and `id -g` on the host). Compose maps the container user to these values.
- Example in `.env`:
- `PUID=1000`
- `PGID=1000`
- Without this, creating events, uploads, thumbnails, or logs can fail with "Permission denied".
Open `http://localhost:3000` and log in with the credentials from your `.env`.
**ARM64 (aarch64) systems:** Pre-built images include native `linux/arm64`, no platform flags or emulation needed. If you're on an older image tag that's still amd64-only, see [docker-compose.amd64.override.yml](docker-compose.amd64.override.yml) for a transitional fallback.
> **Permissions:** Set `PUID` and `PGID` in `.env` to match your host user (`id -u` / `id -g`) so Docker volumes are writable.
## 🔄 Release Channels
See the [Deployment Guide](DEPLOYMENT_GUIDE.md) for reverse proxy setup, SSL, external media, and production configuration.
PicPeak offers two release channels for different needs:
## Screenshots
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
<details>
<summary>Admin Dashboard</summary>
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
<img src="docs/screenshot-dashboard.png" alt="Admin Dashboard" width="800" />
</details>
### Switching Channels
<details>
<summary>Event Management</summary>
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
<img src="docs/screenshots-events.png" alt="Event Management" width="800" />
</details>
<details>
<summary>Analytics</summary>
<img src="docs/screenshot-analytics.png" alt="Analytics" width="800" />
</details>
## Comparison
| | PicPeak | PicDrop | Scrapbook.de |
|---|---|---|---|
| Self-hosted | Yes | No | No |
| Monthly cost | $0 | $29-199 | 19-99 EUR |
| Storage | Unlimited | 50-500 GB | 100-1000 GB |
| Custom branding | Full | Limited | Limited |
| Open source | Yes | No | No |
| API | Yes | Paid | No |
## Tech Stack
- **Backend:** Node.js, Express, PostgreSQL (or SQLite)
- **Frontend:** React, TypeScript, Tailwind CSS
- **Infrastructure:** Docker, Nginx, Redis
- **Processing:** Sharp (images), FFmpeg (video)
## Release Channels
**Stable** (`stable` / `latest`) — Production-ready. Use this for real deployments.
**Beta** (`beta`) — Early access to new features. May have rough edges.
```bash
# For stable releases (default)
PICPEAK_CHANNEL=stable
# Set in .env
PICPEAK_CHANNEL=stable # or beta
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
Then update your containers:
```bash
# Update
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
### Update Notifications
The admin dashboard notifies you when updates are available.
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
## Contributing
```bash
UPDATE_CHECK_ENABLED=false
```
We welcome contributions — bug fixes, features, translations, documentation. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions.
## 📖 Documentation
## Documentation
Full documentation lives at **[docs.picpeak.app](https://docs.picpeak.app)** — deployment, admin settings reference, API docs, webhooks, archive lifecycle, branding, and everything else. Some quick links:
- [Deployment Guide](DEPLOYMENT_GUIDE.md) — Installation, configuration, reverse proxy, external media
- [Admin API (OpenAPI)](docs/picpeak-admin-api.openapi.yaml) — Machine-readable API spec
- [Admin API Quickstart](docs/admin-api-quickstart.md) — Authentication and testing guide
- [Security Policy](SECURITY.md)
- 🚀 [**Deployment**](https://docs.picpeak.app/deployment) - Docker, environment variables, reverse proxy, SSL
- ⚙️ [**Admin Settings**](https://docs.picpeak.app/guides/admin-settings) - Every tab in the Settings panel
- 🎯 [**Creating Events**](https://docs.picpeak.app/guides/creating-events) - Full event field reference
- 💾 [**Backup & Restore**](https://docs.picpeak.app/guides/backup-restore) - Local, S3, rsync destinations
- 🔌 [**API Reference**](https://docs.picpeak.app/api) - REST endpoints, OpenAPI spec, webhooks
- 🪝 [**Webhooks**](https://docs.picpeak.app/features/webhooks) - Event payloads, signing, filters, templates
## Contributors
Project meta:
Thanks to the people whose code, reports, and feedback have shaped PicPeak:
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
- 📜 [**License**](LICENSE) - MIT License
- 🔒 [**Security**](SECURITY.md) - Security policies
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, lazy-loaded folder tree picker, admin-email picker, self-hosted webfont system, gallery header/banner decoupling, and several typed-API refactors.
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, gallery-loading skeleton work, mobile-lightbox overhaul, admin-events search-counter fix, photo-count column, and bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter.
## 🌐 Public Landing Page
If you've contributed and aren't listed here, please open a PR.
Spotlight your studio with a customizable marketing page at `/`:
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
- Use **Reset to default** anytime to restore the bundled template.
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
## License
## 🎯 Use Cases
Perfect for:
- 💒 **Wedding Photographers** - Share ceremony photos securely
- 🎂 **Event Photography** - Birthday parties, corporate events
- 📸 **Portrait Studios** - Client galleries with download limits
- 🏢 **Corporate Events** - Internal photo sharing with branding
- 🎓 **School Photography** - Secure parent access with expiration
## 🏗️ Tech Stack
- **Backend**: Node.js, Express, SQLite/PostgreSQL
- **Frontend**: React, Tailwind CSS, Framer Motion
- **Storage**: Local filesystem (default) or S3-compatible object store (AWS S3, MinIO, R2, B2, Wasabi, Spaces) — see [Storage Backends](#storage-backends)
- **Email**: SMTP with customizable templates
- **Analytics**: Privacy-focused with Umami integration
## 💾 Storage Backends
PicPeak supports two storage backends for photos, thumbnails, hero images, watermarks, and archive zips. Both are configured via environment variables; no code change is required to switch.
| Capability | `STORAGE_BACKEND=local` (default) | `STORAGE_BACKEND=s3` |
|---|---|---|
| Photo / thumbnail / hero storage | Local filesystem under `STORAGE_PATH` | Bucket on any S3-compatible service |
| Admin UI upload | ✅ | ✅ |
| Filesystem auto-import (chokidar watcher) | ✅ | ❌ — disabled (use the upload API) |
| Watermarks, fingerprinting, fragmentation | ✅ | ✅ (materialized to a tmp file just-in-time) |
| Bulk download zips (cached + on-the-fly) | ✅ | ✅ |
| Backups | ✅ | ✅ |
| External media reference mode (`EXTERNAL_MEDIA_ROOT`) | ✅ (always local) | ✅ (still local — not migrated) |
### Switching to an S3-compatible backend
1. Provision a bucket and credentials. The minimum IAM policy is documented in `.env.example`.
2. Set `STORAGE_BACKEND=s3` plus `STORAGE_S3_BUCKET`, `STORAGE_S3_REGION`, `STORAGE_S3_ACCESS_KEY`, `STORAGE_S3_SECRET_KEY`. For non-AWS providers (MinIO, R2, B2, …) also set `STORAGE_S3_ENDPOINT`.
3. If you have existing local content, copy it first: `node backend/scripts/migrate-storage.js --dry-run` then `node backend/scripts/migrate-storage.js`. The script is idempotent and writes a failures CSV.
4. Restart the backend. The startup check pings the bucket and refuses to boot on misconfig.
Note: presigned-URL serving (zero-bandwidth direct downloads from S3) is intentionally **not** in v1 — every request still streams through the backend so watermarks, devtools-detection, and access logging keep working.
## 🔔 Webhooks
PicPeak POSTs event/photo lifecycle notifications to URLs you configure under **Settings → Webhooks**. Each delivery is signed `HMAC-SHA256` with a per-webhook secret in the `X-PicPeak-Signature` header so receivers can verify the request really came from your PicPeak instance.
### Event types
| Event | Fires when |
|---|---|
| `event.created` | Gallery created (admin or API) |
| `event.published` | Draft becomes live (`is_draft: true → false`) — also fires when an event is created with `is_draft=false` |
| `event.archived` | Bulk-archive, manual archive, or auto-archive on expiry |
| `event.expired` | Expiration checker marks the gallery inactive (fires before `event.archived` in the cascade) |
| `photo.uploaded` | Admin upload, API upload, guest upload, or auto-import |
| `photo.deleted` | Single delete, bulk delete (NOT fired per-photo when an event is archived — receivers infer from `event.archived` to avoid flooding) |
### Payload shape
```json
{
"id": "delivery-uuid",
"type": "event.published",
"created_at": "2026-04-28T05:25:00.000Z",
"data": {
"event": { "id": 123, "slug": "wedding-smith", "share_url": "https://..." }
}
}
```
Also sent on every request:
- `X-PicPeak-Signature``HMAC-SHA256(secret, raw_body)` as hex
- `X-PicPeak-Event` — the event type (handy for routing without parsing the body)
- `X-PicPeak-Delivery` — UUID for idempotency on the receiver side
- `User-Agent: PicPeak-Webhooks/1.0`
### Verifying signatures
**Node.js**
```js
const crypto = require('crypto');
function verify(secret, rawBody, signature) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
const a = Buffer.from(expected, 'hex');
const b = Buffer.from(signature, 'hex');
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
}
```
**Python**
```python
import hmac, hashlib
def verify(secret: str, raw_body: bytes, signature: str) -> bool:
expected = hmac.new(secret.encode(), raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, signature)
```
**curl + openssl** (one-liner for a quick replay)
```sh
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
[ "$SIG" = "$RECEIVED_SIG" ] && echo OK || echo MISMATCH
```
### Retries + observability
- `2xx` → success, recorded with latency
- Non-`2xx` or network error → exponential backoff: `1m → 5m → 30m → 2h → 12h`, max 5 attempts
- After max attempts: status `failed`, surfaces in **Settings → Webhooks → Deliveries** with a "Replay" button
- Up to 5 deliveries in flight at once; one slow consumer can't block others (configurable via `WEBHOOK_DELIVERY_CONCURRENCY`)
- Response body truncated to 1KB before storage so chatty receivers don't bloat the audit log
The deliveries page (`/admin/webhooks/:id/deliveries`) shows every attempt with timestamp, status, HTTP code, latency, payload sent, signature, and response. Click "Send test event" to fire a synthetic delivery for any event type.
### SSRF protection
Webhook URLs are validated against the same private-IP blocklist used elsewhere in the app — loopback, private RFC1918 ranges, link-local, `.local`/`.internal` hostnames, cloud metadata endpoints. The check runs both at create time and per-delivery (DNS-rebinding mitigation).
For local development with a receiver on the same machine or docker network, set `WEBHOOK_ALLOW_PRIVATE_URLS=true`. Production deployments must leave this OFF.
## 💻 System Requirements
### Minimum Requirements
- **CPU**: 2 CPU cores
- **RAM**: 2GB minimum
- **Storage**: 20GB minimum (plus photo storage needs)
- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2
- **Node.js**: v18.0.0 or higher
- **Database**: SQLite (included) or PostgreSQL 12+
### Docker Requirements (Recommended)
- **Docker**: v20.10.0+
- **Docker Compose**: v2.0.0+
### Video Support Requirements
When enabling video uploads, consider these additional resources:
| Resource | Recommendation | Notes |
|----------|----------------|-------|
| **RAM** | 4GB+ recommended | FFmpeg processing requires more memory |
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
**Technical Notes:**
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
- Maximum upload size: **10GB per video file**
- Chunked upload support for files >100MB (resumable uploads)
- Supported formats: MP4, WebM, MOV, AVI
- Video thumbnails are automatically generated from the first few seconds
**For Nginx/Reverse Proxy:**
If using Nginx, increase the client max body size:
```nginx
client_max_body_size 10G;
proxy_read_timeout 3600;
proxy_send_timeout 3600;
```
## 🤝 Contributing
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
See our [Contributing Guide](CONTRIBUTING.md) for details.
## 📊 Comparison with Alternatives
| Feature | PicPeak | PicDrop | Scrapbook.de |
|---------|---------|---------|--------------|
| Self-Hosted | ✅ | ❌ | ❌ |
| Custom Branding | ✅ Full | Limited | Limited |
| Monthly Cost | $0 | $29-199 | €19-99 |
| Storage Limit | Unlimited* | 50-500GB | 100-1000GB |
| Client Uploads | ✅ | ✅ | ✅ |
| API Access | ✅ | Paid | ❌ |
| Open Source | ✅ | ❌ | ❌ |
*Limited only by your server storage
## 🛡️ Security
PicPeak takes security seriously:
- 🔐 Password hashing with bcrypt
- 🎫 JWT-based authentication
- 🚦 Rate limiting on all endpoints
- 🛡️ CORS protection
- 📝 Activity logging
- 🔒 Secure file access
Found a security issue? Please open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
## 📸 Screenshots
### 🎛️ **Admin Dashboard**
Get a complete overview of your photo galleries, analytics, and system status.
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
### 📊 **Analytics & Insights**
Track gallery performance, view statistics, and monitor user engagement.
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
### 📁 **Event Management**
Organize and manage your photo galleries with intuitive event management tools.
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
### ✨ **Key Interface Highlights**
<details>
<summary>👆 Click to see more interface details</summary>
#### What makes PicPeak's interface special:
- **🎨 Clean Design**: Modern, photographer-friendly interface
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
- **⚡ Fast Loading**: Optimized for quick photo browsing
- **🔒 Secure Access**: Password-protected galleries with expiration
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
</details>
## 🗺️ Roadmap
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
### 🚧 Beta Features (Use at your own risk)
These features are currently in beta testing and may have limited functionality or stability:
| Feature | Description | Status |
|---------|-------------|--------|
| **Simple Deployment Script** | One-click deployment script for quick server setup with automated configuration and dependency installation | 🧪 Beta |
### 📋 Future Enhancements
| Feature | Description | Priority | Status |
|---------|-------------|----------|---------|
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
| **External Media Library (Reference Mode)** | Use an external folder library as a readonly source with import and ondemand thumbnail generation | High | ✅ Implemented |
| **Download Protection** | Advanced image protection system with canvas rendering, invisible watermarking, right-click prevention, and DevTools detection to protect photos from unauthorized downloads | High | ✅ Implemented |
| **Gallery Templates** | Multiple gallery layouts (grid, masonry, carousel, timeline, hero, mosaic) with custom CSS styling support. Includes starter templates like Apple Liquid Glass for complete visual customization | Medium | ✅ Implemented |
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | ✅ Implemented |
| **Filtering & Export Options** | Filter photos by likes, ratings, comments, or favorites. Search by filename. Sort by date, name, size, or rating. Export filtered selections as ZIP or generate Capture One/Lightroom-compatible file lists for professional workflows | Medium | ✅ Implemented |
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
## ☕ Support the Project
PicPeak is free, open source, and self-hostable forever. If it saves you time or replaces a paid subscription, consider buying me a coffee — it directly funds the time spent on new features, bug fixes, and keeping the demo + docs running.
<p align="left">
<a href="https://buymeacoffee.com/theluap" target="_blank">
<img src="https://img.buymeacoffee.com/button-api/?text=Buy%20me%20a%20coffee&emoji=☕&slug=theluap&button_colour=FFDD00&font_colour=000000&font_family=Cookie&outline_colour=000000&coffee_colour=ffffff" alt="Buy Me A Coffee" />
</a>
</p>
Other ways to support without spending anything: ⭐ star the repo, share it with photographer friends, file good bug reports, or open a PR.
## 🙏 Acknowledgments
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
### 👥 Contributors
A huge thank you to the people whose code, reports, and feedback have shaped PicPeak:
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, the lazy-loaded folder tree picker, the admin-email picker on event creation, the data-driven self-hosted webfont system, the gallery header/banner decoupling, and several typed-API refactors. Consistently raises the bar with thoughtful PRs.
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, the gallery-loading skeleton work, the redirection cleanup, the mobile-lightbox overhaul, the admin-events search-counter fix, the photo-count column, and the bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter — the kind of feedback loop that keeps the project useful for real deployments.
If you've contributed and aren't listed here, please open a PR — this list is meant to grow.
### 🤖 AI-Assisted Development
This project was generated with the assistance of AI technology, but has been:
-**Fully tested end-to-end** by human developers
- 🔒 **Security audited** with comprehensive security checks
- 👨‍💻 **Human-reviewed** for code quality and best practices
- 🧪 **Production-tested** in real-world scenarios
We believe in transparent development practices and the responsible use of AI as a tool to accelerate development while maintaining high standards of quality and security.
## 📄 License
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
## 🚀 Ready to Get Started?
1.**Star this repository** to show your support
2. 📖 Read the [docs at docs.picpeak.app](https://docs.picpeak.app)
3. 🐛 Report issues or request features
4. 🤝 Join our community and contribute!
MIT — use it for personal or commercial projects.
---
<p align="center">
Made with ❤️ by photographers, for photographers
<br>
<a href="https://www.picpeak.app">Homepage</a> •
<a href="https://demo.picpeak.app">Live Demo</a> •
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
<a href="https://docs.picpeak.app">Documentation</a> •
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
<a href="https://www.picpeak.app">Homepage</a> · <a href="https://demo.picpeak.app">Live Demo</a> · <a href="DEPLOYMENT_GUIDE.md">Docs</a> · <a href="https://github.com/the-luap/picpeak/issues">Issues</a>
</p>
+9 -4
View File
@@ -35,12 +35,15 @@ RUN apk upgrade --no-cache
RUN npm install -g npm@10
# Install dumb-init for proper signal handling, postgresql-client for database
# checks, and ffmpeg for video upload support. Alpine's ffmpeg package ships
# both `ffmpeg` and `ffprobe` built natively against musl libc — the npm
# checks, ffmpeg for video upload support, and su-exec for the root → nodejs
# privilege drop in wait-for-db.sh (see #484: container starts as root so it
# can chown bind-mounted host volumes to UID 1001, then re-execs as nodejs
# before running the app). Alpine's ffmpeg package ships both `ffmpeg` and
# `ffprobe` built natively against musl libc — the npm
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
# pipeline calls via fluent-ffmpeg.ffprobe()).
RUN apk add --no-cache dumb-init postgresql-client ffmpeg
RUN apk add --no-cache dumb-init postgresql-client ffmpeg su-exec
# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
@@ -56,7 +59,9 @@ RUN chmod -R a+r /app && chmod +x wait-for-db.sh
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
chown -R nodejs:nodejs storage data logs
USER nodejs
# No USER directive — the container starts as root so wait-for-db.sh can
# chown bind-mounted host directories to UID 1001 before dropping privs
# via su-exec. See #484 for the fresh-install restart loop this avoids.
EXPOSE 3000
@@ -137,6 +137,41 @@ describe.each(backendCases())('imageProcessor through $name', ({ setup }) => {
expect(await storage.exists(key)).toBe(true);
});
test('generatePreviewImage writes to /previews and skips enlargement of small originals', async () => {
const src = await makeSourceJpeg(tmpDir, 'preview-source.jpg');
const key = await imageProcessor.generatePreviewImage(src);
expect(key).toBe('previews/preview_preview-source.jpg');
expect(await storage.exists(key)).toBe(true);
if (storage.kind() === 'local') {
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
expect(meta.format).toBe('jpeg');
// Source is 800x600 and default longEdge is 1920 with
// withoutEnlargement: true → preview must NOT be upscaled.
expect(meta.width).toBe(800);
expect(meta.height).toBe(600);
}
});
test('generatePreviewImage shrinks oversized images to fit longEdge while preserving aspect', async () => {
const src = await makeSourceJpeg(tmpDir, 'preview-shrink.jpg');
const key = await imageProcessor.generatePreviewImage(src, { longEdge: 400 });
expect(await storage.exists(key)).toBe(true);
if (storage.kind() === 'local') {
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
// 800x600 → fit:'inside' inside 400×400 → 400×300.
expect(meta.width).toBe(400);
expect(meta.height).toBe(300);
}
});
test('isPreviewValid returns true for a real preview and false for a missing key', async () => {
const src = await makeSourceJpeg(tmpDir, 'preview-valid.jpg');
const key = await imageProcessor.generatePreviewImage(src);
expect(await imageProcessor.isPreviewValid(key)).toBe(true);
expect(await imageProcessor.isPreviewValid('previews/does-not-exist.jpg')).toBe(false);
});
test('isThumbnailValid returns true for a good thumbnail and false for nothing', async () => {
const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg');
const key = await imageProcessor.generateThumbnail(src);
@@ -0,0 +1,123 @@
/**
* Pin the date-field normalisation in adminUsers transformer (#485).
*
* The Users page crashed on native/SQLite installs because Postgres
* returned ISO strings while SQLite returned epoch-millisecond
* integers, and the frontend `parseISO()` blew up on numbers with
* "e.split is not a function". The transformer now coerces every
* shape to an ISO 8601 string before serialising.
*
* These tests guard the contract so a future refactor can't quietly
* regress and re-break the same page on the same DB.
*/
const adminUsersRoute = require('../../src/routes/adminUsers');
const { toIso, transformUser, transformInvitation } = adminUsersRoute.__test;
describe('toIso', () => {
it('passes null and undefined through unchanged', () => {
expect(toIso(null)).toBeNull();
expect(toIso(undefined)).toBeUndefined();
// Empty string also short-circuits — important so an unset
// last_login renders as "Never" instead of 1970-01-01T00:00:00Z.
expect(toIso('')).toBe('');
});
it('coerces an integer epoch (SQLite shape) to an ISO 8601 string', () => {
// 2026-05-14T10:00:00.000Z, in epoch ms.
const epochMs = 1778752800000;
expect(toIso(epochMs)).toBe('2026-05-14T10:00:00.000Z');
});
it('coerces a stringified large integer to an ISO 8601 string', () => {
// Some SQLite drivers stringify large integers because they
// overflow JS safe-integer in the driver's serialiser. Re-coerce
// so the frontend doesn't try to parseISO('1778752800000').
expect(toIso('1778752800000')).toBe('2026-05-14T10:00:00.000Z');
});
it('coerces a Date instance via toISOString', () => {
const d = new Date('2026-01-01T12:34:56.000Z');
expect(toIso(d)).toBe('2026-01-01T12:34:56.000Z');
});
it('passes an existing ISO string through unchanged', () => {
const iso = '2026-05-14T10:00:00.000Z';
expect(toIso(iso)).toBe(iso);
});
it('passes a non-numeric short string (e.g. truncated date) through unchanged', () => {
// Defensive: anything that isn't a 10+ digit integer string is
// treated as already-stringified — the date library will surface
// the failure cleanly if it's malformed, rather than the
// transformer silently rewriting it.
expect(toIso('2026-05-14')).toBe('2026-05-14');
});
});
describe('transformUser', () => {
it('normalises last_login, created_at, updated_at coming from SQLite', () => {
const sqliteRow = {
id: 1,
username: 'admin',
email: 'admin@example.com',
is_active: 1,
last_login: 1778752800000, // epoch ms
last_login_ip: '127.0.0.1',
created_at: 1778751144600, // epoch ms
updated_at: 1778751242320, // epoch ms
role_id: 1,
role_name: 'super_admin',
role_display_name: 'Super Admin',
created_by_username: null,
};
const out = transformUser(sqliteRow);
expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
expect(out.createdAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(out.updatedAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
// Other fields untouched.
expect(out.username).toBe('admin');
expect(out.lastLoginIp).toBe('127.0.0.1');
});
it('leaves Postgres ISO strings intact', () => {
const pgRow = {
id: 2,
username: 'second',
email: 'second@example.com',
is_active: true,
last_login: '2026-05-14T10:00:00.000Z',
created_at: '2026-05-13T08:00:00.000Z',
updated_at: '2026-05-14T09:00:00.000Z',
};
const out = transformUser(pgRow);
expect(out.lastLogin).toBe('2026-05-14T10:00:00.000Z');
expect(out.createdAt).toBe('2026-05-13T08:00:00.000Z');
expect(out.updatedAt).toBe('2026-05-14T09:00:00.000Z');
});
it('keeps last_login null when the user has never logged in', () => {
const out = transformUser({
id: 3, username: 'fresh', email: 'fresh@example.com',
is_active: 1, last_login: null,
});
expect(out.lastLogin).toBeNull();
});
});
describe('transformInvitation', () => {
it('normalises expires_at and created_at from SQLite epoch-ms', () => {
const out = transformInvitation({
id: 9,
email: 'invitee@example.com',
expires_at: 1779357600000,
created_at: 1778752800000,
role_name: 'admin',
invited_by: 'admin',
});
expect(out.expiresAt).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
expect(out.createdAt).toBe('2026-05-14T10:00:00.000Z');
});
});
@@ -0,0 +1,76 @@
/**
* Pure-logic tests for the #493 download-filename helpers that don't depend
* on the DB (those are covered by the route integration suite).
*/
const {
pickRawDownloadName,
getZipEntryNames,
} = require('../../src/services/downloadFilenameService');
describe('pickRawDownloadName', () => {
it('returns the storage filename when the toggle is off', () => {
expect(
pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' }, false)
).toBe('slug_001.jpg');
});
it('returns original_filename when the toggle is on', () => {
expect(
pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' }, true)
).toBe('DSC_1.jpg');
});
it('falls back to storage filename when original_filename is missing', () => {
expect(
pickRawDownloadName({ id: 1, filename: 'slug_001.jpg', original_filename: null }, true)
).toBe('slug_001.jpg');
});
it('produces a stable last-resort name when both are missing', () => {
expect(pickRawDownloadName({ id: 42 }, true)).toBe('photo-42.jpg');
});
});
describe('getZipEntryNames', () => {
it('uses original filenames with deterministic suffixes on collision', () => {
const photos = [
{ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1234.jpg' },
{ id: 2, filename: 'slug_002.jpg', original_filename: 'DSC_1234.jpg' },
{ id: 3, filename: 'slug_003.jpg', original_filename: 'DSC_1235.jpg' },
];
expect(getZipEntryNames(photos, true)).toEqual([
'DSC_1234.jpg',
'DSC_1234_1.jpg',
'DSC_1235.jpg',
]);
});
it('falls back to storage filename per-photo when original is missing', () => {
const photos = [
{ id: 1, filename: 'slug_001.jpg', original_filename: 'DSC_1.jpg' },
{ id: 2, filename: 'slug_002.jpg', original_filename: null },
];
expect(getZipEntryNames(photos, true)).toEqual([
'DSC_1.jpg',
'slug_002.jpg',
]);
});
it('returns storage filenames when the toggle is off, dedup still applies', () => {
const photos = [
{ id: 1, filename: 'a.jpg', original_filename: 'DSC_1.jpg' },
{ id: 2, filename: 'a.jpg', original_filename: 'DSC_2.jpg' },
];
expect(getZipEntryNames(photos, false)).toEqual(['a.jpg', 'a_1.jpg']);
});
it('sanitizes path-traversal attempts that sneak into original_filename', () => {
const photos = [
{ id: 1, filename: 'slug_001.jpg', original_filename: '../etc/passwd' },
];
const [name] = getZipEntryNames(photos, true);
expect(name).not.toContain('..');
expect(name).not.toContain('/');
});
});
Binary file not shown.
-50
View File
@@ -1,50 +0,0 @@
#!/bin/sh
# init-production.sh - Production initialization script
set -e
echo "🚀 Initializing PicPeak Production Environment..."
# Wait for services to be ready
echo "⏳ Waiting for database to be fully ready..."
sleep 3
# Fix permissions if running as root (shouldn't happen with proper Dockerfile)
if [ "$(id -u)" = "0" ]; then
echo "🔧 Fixing file permissions..."
chown -R nodejs:nodejs /app/storage /app/data /app/logs 2>/dev/null || true
fi
# Create required directories
echo "📁 Creating required directories..."
mkdir -p /app/storage/events/active \
/app/storage/events/archived \
/app/storage/thumbnails \
/app/storage/uploads/logos \
/app/storage/uploads/favicons \
/app/data \
/app/logs
# Run migrations with safe runner
echo "🗄️ Running database migrations (safe mode)..."
NODE_ENV=production npm run migrate:safe
# Create admin user if environment variables are set
if [ -n "$ADMIN_EMAIL" ] && [ -n "$ADMIN_PASSWORD" ]; then
echo "👤 Creating admin user..."
node scripts/create-admin.js \
--email "$ADMIN_EMAIL" \
--username "${ADMIN_USERNAME:-admin}" \
--password "$ADMIN_PASSWORD" || echo "Admin user might already exist"
fi
# Initialize email configuration if variables are set
if [ -n "$SMTP_HOST" ]; then
echo "📧 Email configuration detected via environment variables"
fi
echo "✅ Production initialization complete!"
echo "🌐 Starting application server..."
# Start the application
exec node server.js
@@ -83,11 +83,15 @@ async function up() {
});
}
// Add indexes if they don't exist
// Add indexes if they don't exist. backup_runs (created in 029) tracks
// chronology via `started_at` — the original `created_at` reference
// here was a bug that emitted a "column does not exist" ERROR in the
// postgres log on every fresh install (silently caught below). See
// migration 105 for the matching back-fix on already-applied installs.
try {
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_mode_status ON backup_runs(backup_mode, status)');
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_parent ON backup_runs(parent_backup_id)');
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_created_mode ON backup_runs(created_at, backup_mode)');
await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_started_mode ON backup_runs(started_at, backup_mode)');
} catch (error) {
console.log('Note: Some indexes may already exist, continuing...');
}
@@ -144,16 +148,17 @@ async function up() {
});
}
// Add composite indexes for common query patterns
// Add composite indexes for common query patterns. Same `started_at`
// correction as above — `created_at` doesn't exist on backup_runs.
try {
await db.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
ON backup_runs(created_at DESC)
CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
ON backup_runs(started_at DESC)
WHERE status = 'completed' AND backup_mode = 'full';
`);
await db.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
ON backup_runs(parent_backup_id, created_at)
CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
ON backup_runs(parent_backup_id, started_at)
WHERE backup_mode = 'incremental';
`);
} catch (error) {
@@ -194,10 +199,13 @@ async function down() {
});
}
// Drop indexes
// Drop indexes. `idx_backup_runs_created_mode` is the legacy name
// shipped by an earlier revision of this migration; kept in the drop
// list so a down() against any historic state cleans up either name.
try {
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_mode_status');
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_parent');
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_started_mode');
await db.raw('DROP INDEX IF EXISTS idx_backup_runs_created_mode');
} catch (error) {
// Ignore errors if indexes don't exist
@@ -0,0 +1,38 @@
/**
* Migration: re-add the configurable upload batch size setting (#509).
*
* Originally shipped via PR #214 (#208 fix) — users behind Cloudflare
* Tunnel and other reverse proxies with per-request size caps need to
* bound the chunked-upload size so they don't lose every batch >100MB.
* That migration + frontend wiring was lost during a `Merge main into
* beta for release/beta-to-main` resolution that picked main's older
* tree over beta's, silently deleting the file and reinstating the
* hardcoded 500MB chunk in PhotoUpload.tsx.
*
* Re-introducing the exact same migration here. Idempotent: skips the
* insert if the row already exists (e.g. installs that did go through
* the original 072 between #214 merge and the main-into-beta merge,
* where the migrations-table row was preserved even after the file
* was deleted).
*/
exports.up = async function(knex) {
const exists = await knex('app_settings')
.where({ setting_key: 'general_max_upload_batch_size_mb' })
.first();
if (!exists) {
await knex('app_settings').insert({
setting_key: 'general_max_upload_batch_size_mb',
setting_value: JSON.stringify(95),
setting_type: 'general',
updated_at: new Date()
});
}
};
exports.down = async function(knex) {
await knex('app_settings')
.where({ setting_key: 'general_max_upload_batch_size_mb' })
.del();
};
@@ -0,0 +1,43 @@
/**
* Migration: Promotional banner text alignment (#482).
*
* Adds `branding_promo_alignment` to app_settings — admin-controlled
* horizontal alignment for the gallery promotional banner content
* (#440 / #482). Reuses the existing app_settings shape that
* branding_promo_markdown / branding_promo_position already use.
*
* Default 'center' so the banner aligns with the gallery footer
* (which is full-width center-aligned). The previous default left
* the markdown left-aligned in a max-w-3xl block, which Rekoo-PS
* reported as visually offset from the footer.
*
* Allowed values: 'left' | 'center' | 'right' — validated on the
* write path in adminSettings.js, not enforced by the column type
* (we use varchar instead of CHECK so the value can be extended
* later — e.g. 'justify' — without another schema migration).
*
* Idempotent: skips the insert when the row already exists.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('app_settings'))) return;
const existing = await knex('app_settings')
.where('setting_key', 'branding_promo_alignment')
.first();
if (existing) return;
await knex('app_settings').insert({
setting_key: 'branding_promo_alignment',
setting_value: JSON.stringify('center'),
setting_type: 'branding',
updated_at: new Date(),
});
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('app_settings'))) return;
await knex('app_settings')
.where('setting_key', 'branding_promo_alignment')
.del();
};
@@ -0,0 +1,59 @@
/**
* Migration: Lightbox medium-resolution preview tier (#492).
*
* Adds:
* - photos.preview_path (nullable VARCHAR) — storage key for the
* per-photo preview JPEG; populated lazily by ensurePreviewImage
* on first lightbox open (or eagerly by the regenerate-previews
* admin endpoint). Mirrors photos.thumbnail_path / hero_path.
* - app_settings.lightbox_preview_enabled (boolean, default false)
* — opt-in toggle. Off by default because the new tier costs
* ~200500 KB per photo on disk; admins flip it on once they've
* decided the perf win is worth the storage.
*
* No backfill of existing photos here — preview generation is lazy
* by design and a separate "Regenerate previews" admin button covers
* eager backfill when an admin wants to warm the cache for an
* existing gallery.
*
* Idempotent: every step checks for existing state.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('photos'))) return;
if (!(await knex.schema.hasColumn('photos', 'preview_path'))) {
await knex.schema.alterTable('photos', (table) => {
table.string('preview_path');
});
}
if (!(await knex.schema.hasTable('app_settings'))) return;
const existing = await knex('app_settings')
.where('setting_key', 'lightbox_preview_enabled')
.first();
if (!existing) {
await knex('app_settings').insert({
setting_key: 'lightbox_preview_enabled',
// SQLite stores TEXT, Postgres JSONB — JSON-stringify so both
// backends round-trip a recognisable boolean shape, matching
// how other branding_* boolean settings are stored today.
setting_value: JSON.stringify(false),
setting_type: 'thumbnail',
updated_at: new Date(),
});
}
};
exports.down = async function(knex) {
if (await knex.schema.hasTable('app_settings')) {
await knex('app_settings')
.where('setting_key', 'lightbox_preview_enabled')
.del();
}
if (await knex.schema.hasTable('photos') && await knex.schema.hasColumn('photos', 'preview_path')) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('preview_path');
});
}
};
@@ -0,0 +1,55 @@
/**
* Migration: back-fix the backup_runs indexes that migration 035 tried to
* create on the nonexistent `created_at` column (#484).
*
* On Postgres, 035's `CREATE INDEX ... ON backup_runs(created_at, ...)`
* statements raised `column "created_at" does not exist`, which was caught
* silently by the wrapping try/catch — so the migration "succeeded" but the
* indexes never got created. Fresh installs saw the ERROR in the postgres
* log; existing installs simply ran without those indexes.
*
* 035 has now been corrected to use `started_at` (the column that does
* exist on backup_runs and carries the same chronological semantics).
* This migration creates the same indexes idempotently for any deployment
* whose 035 silently failed — no-op on fresh installs because 035 already
* built them.
*
* SQLite: partial indexes (`WHERE …`) work but cross-table semantics differ
* slightly from Postgres; we still emit them because the only consumer is
* the backup-history query in `backupService` and it issues identical SQL
* across both backends.
*/
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('backup_runs'))) return;
if (!(await knex.schema.hasColumn('backup_runs', 'started_at'))) return;
// Plain composite index — matches the corrected statement in 035.
await knex.raw(
'CREATE INDEX IF NOT EXISTS idx_backup_runs_started_mode ON backup_runs(started_at, backup_mode)'
);
// Partial indexes only get created if backup_mode exists (035 added it).
if (!(await knex.schema.hasColumn('backup_runs', 'backup_mode'))) return;
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
ON backup_runs(started_at DESC)
WHERE status = 'completed' AND backup_mode = 'full'
`);
if (await knex.schema.hasColumn('backup_runs', 'parent_backup_id')) {
await knex.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
ON backup_runs(parent_backup_id, started_at)
WHERE backup_mode = 'incremental'
`);
}
};
exports.down = async function(knex) {
if (!(await knex.schema.hasTable('backup_runs'))) return;
await knex.raw('DROP INDEX IF EXISTS idx_backup_runs_started_mode');
await knex.raw('DROP INDEX IF EXISTS idx_backup_runs_recent_successful');
await knex.raw('DROP INDEX IF EXISTS idx_backup_runs_incremental_chain');
};
@@ -0,0 +1,121 @@
/**
* Migration: seed Spanish (es) email-template translations (#510).
*
* Contributed by @AloePacci on issue #510. Covers the four
* customer-facing gallery delivery templates that already had
* en/de/nl/pt/ru rows from migration 075. Templates without an `es`
* row (admin_*, backup_*, restore_*, customer_*, version_update_*)
* continue to fall back to `en` via the resolution chain in
* emailProcessor.processTemplate — no functional gap, just untranslated
* copy until someone fills them in.
*
* Same idempotency pattern as 099_seed_missing_email_template_translations:
* checks (template_id, language) before inserting so re-runs are safe.
*/
const TRANSLATIONS = {
gallery_created: {
es: {
subject: 'Su galería de fotos está lista!',
body_html: `<h2>Galería creada con éxito</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" ha sido creada con éxito!</p>
<p><strong>Detalles de la galería:</strong></p>
<ul>
<li>Fecha del evento: {{event_date}}</li>
<li>Enlace de la galería: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Contraseña: {{gallery_password}}</li>
<li>Expira en: {{expiry_date}}</li>
</ul>
<p>Comparta este enlace y contraseña con sus invitados para que puedan ver y descargar las fotos.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: 'Galería creada con éxito\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" ha sido creada con éxito!\n\nEnlace de la galería: {{gallery_link}}\nContraseña: {{gallery_password}}\nExpira en: {{expiry_date}}',
},
},
expiration_warning: {
es: {
subject: 'Su galería de fotos expirará pronto',
body_html: `<h2>Galería expirando pronto</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" expirará en {{days_remaining}} días.</p>
<p>Después de la expiración, la galería será archivada y ya no estará accesible para los invitados.</p>
<p><a href="{{gallery_link}}">Visitar galería</a></p>`,
body_text: 'Galería expirando pronto\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" expirará en {{days_remaining}} días.\n\nGalería: {{gallery_link}}',
},
},
gallery_expired: {
es: {
subject: 'Su galería de fotos está caducada',
body_html: `<h2>Galería vencida</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" ha caducado y por tanto ya no es accesible.</p>
<p>Las fotos han sido archivadas. Si necesita acceso, por favor póngase en contacto con el administrador a través de {{admin_email}}.</p>`,
body_text: 'Galería caducada\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" ha caducado y ya no está accesible.\n\nContacto: {{admin_email}}',
},
},
archive_complete: {
es: {
subject: 'Archivado completado: {{event_name}}',
body_html: `<h2>Archivado completado</h2>
<p>Estimado {{host_name}},</p>
<p>Su galería de fotos "{{event_name}}" ha sido archivada con éxito.</p>
<p><strong>Detalles del archivo:</strong></p>
<ul>
<li>Número de fotos: {{photo_count}}</li>
<li>Tamaño del archivo: {{archive_size}}</li>
<li>Fecha del archivado: {{archive_date}}</li>
</ul>`,
body_text: 'Archivado completado\n\nEstimado {{host_name}},\n\nSu galería de fotos "{{event_name}}" ha sido archivada con éxito.\n\nFotos: {{photo_count}}\nTamaño: {{archive_size}}',
},
},
};
exports.up = async function(knex) {
if (!(await knex.schema.hasTable('email_templates'))) return;
if (!(await knex.schema.hasTable('email_template_translations'))) return;
const rows = await knex('email_templates')
.whereIn('template_key', Object.keys(TRANSLATIONS))
.select('id', 'template_key');
const keyToId = Object.fromEntries(rows.map((r) => [r.template_key, r.id]));
let inserted = 0;
let skipped = 0;
for (const [key, perLocale] of Object.entries(TRANSLATIONS)) {
const templateId = keyToId[key];
if (!templateId) continue;
for (const [language, content] of Object.entries(perLocale)) {
const existing = await knex('email_template_translations')
.where({ template_id: templateId, language })
.first();
if (existing) {
skipped += 1;
continue;
}
await knex('email_template_translations').insert({
template_id: templateId,
language,
subject: content.subject,
body_html: content.body_html,
body_text: content.body_text,
created_at: new Date(),
updated_at: new Date(),
});
inserted += 1;
}
}
console.log(`106_seed_es_email_template_translations: inserted=${inserted}, skipped=${skipped}`);
};
exports.down = async function(knex) {
// No-op: same rationale as 099. An admin may have hand-edited the
// `es` rows in the Templates UI after this migration ran, and we
// can't tell apart inserted-by-us rows from edited-by-admin rows.
// Rollback by hand if you truly need to drop them.
};
+55 -6
View File
@@ -38,7 +38,48 @@ async function markMigrationAsApplied(filename) {
// Detect existing schema and mark migrations as applied
async function detectExistingSchema() {
console.log('Detecting existing schema...');
// Modern-bootstrap fingerprint check (#530).
//
// A DB with the post-initializeDatabase state (photo_categories +
// cms_pages present, which db.js:initializeDatabase() creates as
// part of the consolidated modern bootstrap) but an empty migrations
// table is a recovery scenario — either restored from a backup that
// lost the migrations table, or someone invoked initializeDatabase()
// outside the migration runner.
//
// Treating this as a regular "existing deployment" runs the legacy
// chain first, which renames email_templates.subject → subject_en
// (legacy/008). Then core/029 fails when it tries to insert email
// templates referencing the pre-rename `subject` column. Fresh
// installs avoid this by running ONLY core migrations (core/059
// handles the rename later, after core/029 has inserted templates).
// Real legacy upgrades avoid it because their migrations table
// already records that legacy/008028 ran historically.
//
// The fix: when the modern bootstrap fingerprint is detected, mark
// every legacy migration as applied. This matches what fresh
// installs do (skip legacy entirely) and keeps the legacy chain
// from operating on a schema state it doesn't expect. Real legacy
// upgrades hit no-op markings here because they already have their
// migrations recorded.
const hasPhotoCategoriesTable = await db.schema.hasTable('photo_categories');
const hasCmsPagesTable = await db.schema.hasTable('cms_pages');
if (hasPhotoCategoriesTable && hasCmsPagesTable) {
const legacyDir = path.join(__dirname, 'legacy');
try {
const legacyFiles = await fs.readdir(legacyDir);
const legacyMigrations = legacyFiles.filter((f) => /^\d{3}_.*\.js$/.test(f));
for (const filename of legacyMigrations) {
await markMigrationAsApplied(filename);
}
} catch (err) {
// Non-fatal — only legacy dir absence (very-old test setups)
// would land here. Original table-based markers below still run.
console.log(`Could not enumerate legacy migrations: ${err.message}`);
}
}
const tableChecks = [
{ table: 'events', migration: '001_init.js' },
{ table: 'photos', migration: '001_init.js' },
@@ -49,7 +90,7 @@ async function detectExistingSchema() {
{ table: 'backup_runs', migration: '029_add_backup_service_tables.js' },
{ table: 'gallery_feedback', migration: '033_add_gallery_feedback.js' },
];
for (const check of tableChecks) {
const exists = await db.schema.hasTable(check.table);
if (exists) {
@@ -126,18 +167,26 @@ async function runMigrations() {
const hasActivityLogsTable = await db.schema.hasTable('activity_logs');
// Get applied migrations
const appliedMigrations = await db('migrations').select('filename');
const appliedFilenames = appliedMigrations.map(m => m.filename);
let appliedMigrations = await db('migrations').select('filename');
let appliedFilenames = appliedMigrations.map(m => m.filename);
// Check if this is a new deployment
// It's new if no essential tables exist OR no migrations have been applied
const hasEssentialTables = hasEventsTable && hasPhotosTable && hasAdminTable && hasActivityLogsTable;
const isDatabaseEmpty = !hasEventsTable && !hasPhotosTable && !hasAdminTable && !hasActivityLogsTable;
const isNewDeployment = isDatabaseEmpty || (appliedFilenames.length === 0 && !hasEssentialTables);
// Only detect existing schema for truly existing deployments
if (!isNewDeployment) {
await detectExistingSchema();
// detectExistingSchema may have inserted rows into the migrations
// table (e.g. for 004_add_categories_and_cms.js when photo_categories
// already exists). Re-query so the iteration below sees the up-to-date
// applied set — otherwise the loop attempts those migrations again,
// their tx-internal `insert into migrations` conflicts, and postgres
// logs a "duplicate key" ERROR on every fresh-after-partial install.
appliedMigrations = await db('migrations').select('filename');
appliedFilenames = appliedMigrations.map(m => m.filename);
}
// Get migration files from appropriate directories
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.49.1-beta.0",
"version": "3.44.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+10 -1
View File
@@ -161,7 +161,12 @@ const corsOptions = {
callback(null, false);
}
},
credentials: true
credentials: true,
// Expose Content-Disposition so split (cross-origin) frontend
// deployments can read the server's chosen download filename. Used
// by the gallery/admin download flows to honour the #493 "original
// camera filename" toggle on individual photo downloads (#507).
exposedHeaders: ['Content-Disposition'],
};
// Only attach CORS to API endpoints, not static assets
@@ -182,6 +187,10 @@ function composeInlineStyles(payload) {
--brand-accent: ${branding.colors.accent};
--brand-background: ${branding.colors.background};
--brand-text: ${branding.colors.text};
--brand-surface: ${branding.colors.surface || '#ffffff'};
--brand-elevated: ${branding.colors.elevated || '#f5f5f5'};
--brand-border: ${branding.colors.border || '#e5e5e5'};
--brand-muted-text: ${branding.colors.mutedText || '#737373'};
}`);
if (payload.baseCss) {
@@ -35,6 +35,7 @@ const { getStorage } = require('../services/storage');
const {
buildOgMetadata,
handleGalleryOgCover,
isSocialCrawler,
} = require('../services/galleryOgService');
// The service hits two tables in sequence:
@@ -237,3 +238,52 @@ describe('handleGalleryOgCover — 404 unless explicitly opted in', () => {
expect(ensureThumbnail).not.toHaveBeenCalled();
});
});
// Regression for #521 — WhatsApp Business API + 3rd-party preview
// services use UAs that aren't "WhatsApp/X.Y.Z". If isSocialCrawler
// misses them, those requests fall through to the static SPA shell
// and the link preview ends up unbranded.
describe('isSocialCrawler — extended bot coverage (#521)', () => {
it('matches every UA the README/changelog claims to support', () => {
// Pin the contract: each listed UA must hit the crawler path so the
// nginx rewrite + backend OG handler stay in sync. Adding a new UA
// here without also adding it to nginx.conf would silently regress.
const knownBots = [
// Main WhatsApp app
'WhatsApp/2.23.20.0',
// WhatsApp Business / Cloud API variants
'WhatsAppBot/1.0',
'wa-bot/2.0',
// Other messaging app crawlers
'facebookexternalhit/1.1',
'Twitterbot/1.0',
'Slackbot-LinkExpanding 1.0',
'TelegramBot (like TwitterBot)',
// 3rd-party preview services used by business-messaging stacks
'LinkPreview/1.0',
'Slack-ImgProxy/1.0',
];
for (const ua of knownBots) {
expect(isSocialCrawler(ua)).toBe(true);
}
});
it('does not match a regular browser UA', () => {
const browsers = [
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36',
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15',
// Browser UA that happens to contain "Mobile" — guard against an
// over-broad regex landing on it.
'Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 Chrome/120.0 Mobile Safari/537.36',
];
for (const ua of browsers) {
expect(isSocialCrawler(ua)).toBe(false);
}
});
it('returns false for null/empty/undefined UAs', () => {
expect(isSocialCrawler(null)).toBe(false);
expect(isSocialCrawler(undefined)).toBe(false);
expect(isSocialCrawler('')).toBe(false);
});
});
@@ -159,4 +159,33 @@ describe('publicSiteService', () => {
expect(payload.branding.colors.accentDark).toBe('#5C8762');
});
it('exposes surface tokens and uses theme-aware public site CSS', async () => {
const publicSiteRows = buildPublicSiteRows({});
const brandingRows = buildBrandingRows({
themeConfig: {
primaryColor: '#014E4E',
accentColor: '#017C7C',
backgroundColor: '#0D0D0D',
surfaceColor: '#111414',
elevatedColor: '#182222',
surfaceBorderColor: '#1E2E2E',
textColor: '#EBEBEB',
mutedTextColor: '#B6C2C2'
}
});
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.branding.colors.surface).toBe('#111414');
expect(payload.branding.colors.elevated).toBe('#182222');
expect(payload.branding.colors.border).toBe('#1E2E2E');
expect(payload.branding.colors.mutedText).toBe('#B6C2C2');
expect(payload.baseCss).toContain('var(--brand-surface');
expect(payload.baseCss).toContain('var(--brand-muted-text');
});
});
+29 -29
View File
@@ -165,7 +165,7 @@ const DEFAULT_PUBLIC_SITE_CSS = `
body {
margin: 0;
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(180deg, var(--brand-background), #ffffff 55%);
background: linear-gradient(180deg, var(--brand-background), var(--brand-surface, #ffffff) 55%);
color: var(--brand-text);
-webkit-font-smoothing: antialiased;
}
@@ -184,16 +184,16 @@ img {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.03), transparent 65%);
background: linear-gradient(180deg, var(--brand-elevated, rgba(15, 23, 42, 0.03)), transparent 65%);
}
.site-header {
position: sticky;
top: 0;
z-index: 30;
background: rgba(255, 255, 255, 0.92);
background: var(--brand-surface, rgba(255, 255, 255, 0.92));
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
border-bottom: 1px solid var(--brand-border, rgba(15, 23, 42, 0.08));
}
.header-inner {
@@ -238,14 +238,14 @@ img {
.brand-tagline {
margin: 0;
font-size: 0.85rem;
color: rgba(15, 23, 42, 0.65);
color: var(--brand-muted-text, rgba(15, 23, 42, 0.65));
}
.site-nav {
display: flex;
gap: 1rem;
font-size: 0.95rem;
color: rgba(15, 23, 42, 0.65);
color: var(--brand-muted-text, rgba(15, 23, 42, 0.65));
}
.site-nav a {
@@ -334,7 +334,7 @@ img {
.hero__lead {
margin: 0;
max-width: 32rem;
color: rgba(15, 23, 42, 0.72);
color: var(--brand-muted-text, rgba(15, 23, 42, 0.72));
font-size: 1.05rem;
line-height: 1.6;
}
@@ -360,7 +360,7 @@ img {
.hero__stats dd {
margin: 0.35rem 0 0;
color: rgba(15, 23, 42, 0.6);
color: var(--brand-muted-text, rgba(15, 23, 42, 0.6));
font-size: 0.95rem;
}
@@ -372,9 +372,9 @@ img {
.deck {
border-radius: 20px;
padding: 1.75rem;
background: #fff;
background: var(--brand-surface, #fff);
box-shadow: 0 35px 60px -35px rgba(15, 23, 42, 0.35);
border: 1px solid rgba(15, 23, 42, 0.08);
border: 1px solid var(--brand-border, rgba(15, 23, 42, 0.08));
display: grid;
gap: 1.35rem;
}
@@ -384,7 +384,7 @@ img {
}
.deck--secondary {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.08), rgba(15, 23, 42, 0.03));
background: linear-gradient(135deg, var(--brand-surface, #fff), var(--brand-elevated, rgba(15, 23, 42, 0.03)));
}
.deck__header {
@@ -411,14 +411,14 @@ img {
padding-left: 1.1rem;
display: grid;
gap: 0.65rem;
color: rgba(15, 23, 42, 0.68);
color: var(--brand-muted-text, rgba(15, 23, 42, 0.68));
}
.deck__quote {
margin: 0;
font-size: 1.05rem;
line-height: 1.7;
color: rgba(15, 23, 42, 0.78);
color: var(--brand-text, rgba(15, 23, 42, 0.78));
}
.deck__author {
@@ -458,7 +458,7 @@ img {
.section-head p {
margin: 0;
color: rgba(15, 23, 42, 0.65);
color: var(--brand-muted-text, rgba(15, 23, 42, 0.65));
}
.feature-grid {
@@ -469,10 +469,10 @@ img {
}
.feature-grid article {
background: rgba(255, 255, 255, 0.9);
background: var(--brand-surface, rgba(255, 255, 255, 0.9));
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(15, 23, 42, 0.08);
border: 1px solid var(--brand-border, rgba(15, 23, 42, 0.08));
box-shadow: 0 18px 40px -30px rgba(15, 23, 42, 0.28);
}
@@ -504,17 +504,17 @@ img {
.workflow__steps p {
margin: 0;
color: rgba(15, 23, 42, 0.65);
color: var(--brand-muted-text, rgba(15, 23, 42, 0.65));
}
.workflow__browser {
margin: 0;
background: rgba(15, 23, 42, 0.05);
background: var(--brand-elevated, rgba(15, 23, 42, 0.05));
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.1);
border: 1px solid var(--brand-border, rgba(15, 23, 42, 0.1));
padding: 2rem;
text-align: center;
color: rgba(15, 23, 42, 0.55);
color: var(--brand-muted-text, rgba(15, 23, 42, 0.55));
font-size: 0.85rem;
}
@@ -526,9 +526,9 @@ img {
}
.collection-showcase article {
background: rgba(255, 255, 255, 0.92);
background: var(--brand-surface, rgba(255, 255, 255, 0.92));
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
border: 1px solid var(--brand-border, rgba(15, 23, 42, 0.08));
padding: 1.5rem;
box-shadow: 0 18px 45px -32px rgba(15, 23, 42, 0.3);
}
@@ -543,9 +543,9 @@ img {
.story-grid figure {
margin: 0;
padding: 1.75rem;
background: rgba(255, 255, 255, 0.95);
background: var(--brand-surface, rgba(255, 255, 255, 0.95));
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.08);
border: 1px solid var(--brand-border, rgba(15, 23, 42, 0.08));
box-shadow: 0 18px 42px -32px rgba(15, 23, 42, 0.28);
}
@@ -553,12 +553,12 @@ img {
margin: 0 0 1.2rem;
font-size: 1.05rem;
line-height: 1.7;
color: rgba(15, 23, 42, 0.8);
color: var(--brand-text, rgba(15, 23, 42, 0.8));
}
.story-grid figcaption {
font-weight: 600;
color: rgba(15, 23, 42, 0.7);
color: var(--brand-muted-text, rgba(15, 23, 42, 0.7));
}
.cta {
@@ -625,8 +625,8 @@ img {
.site-footer {
padding: 3rem 1.5rem;
background: rgba(15, 23, 42, 0.05);
border-top: 1px solid rgba(15, 23, 42, 0.08);
background: var(--brand-elevated, rgba(15, 23, 42, 0.05));
border-top: 1px solid var(--brand-border, rgba(15, 23, 42, 0.08));
}
.footer-inner {
@@ -644,7 +644,7 @@ img {
.footer-inner p {
margin: 0;
color: rgba(15, 23, 42, 0.65);
color: var(--brand-muted-text, rgba(15, 23, 42, 0.65));
line-height: 1.6;
}
+29 -1
View File
@@ -86,7 +86,16 @@ async function initializeDatabase() {
table.boolean('disable_right_click').defaultTo(false);
table.boolean('watermark_downloads').defaultTo(false);
table.text('watermark_text');
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
// events.hero_photo_id → photos.id is a forward reference (the
// photos table is created later in this same function). Postgres
// rejects FK declarations that reference a non-existent table at
// CREATE TABLE time, so the constraint is added below as an
// ALTER TABLE *after* the photos table exists. SQLite previously
// tolerated the inline declaration because its FK enforcement is
// lazy — the inline form silently became a column with no FK
// metadata. Both backends now go through the same code path.
// (#484, MrGabri's reproduction.)
table.integer('hero_photo_id');
table.boolean('require_password').defaultTo(true);
});
} else {
@@ -213,6 +222,25 @@ async function initializeDatabase() {
table.integer('view_count').defaultTo(0);
table.integer('download_count').defaultTo(0);
});
// Deferred FK: events.hero_photo_id → photos.id. See the comment
// on the events createTable above for why this can't be inline.
// Wrapped in try/catch so a re-run path or an SQLite install that
// already accepted the inline (no-op) declaration doesn't fail
// boot when the constraint already exists in some shape.
try {
await db.schema.alterTable('events', (table) => {
table.foreign('hero_photo_id')
.references('id').inTable('photos')
.onDelete('SET NULL');
});
} catch (err) {
const msg = err?.message || '';
if (!/already exists|duplicate|exists/i.test(msg)) {
throw err;
}
// Constraint already in place — fine, carry on.
}
}
// Access logs table
+2 -1
View File
@@ -3,6 +3,7 @@ const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { slugify } = require('../utils/slug');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver');
@@ -217,7 +218,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
const insertResult = await db('photo_categories').insert({
event_id: archive.id,
name: categoryName,
slug: categoryName.toLowerCase().replace(/[^a-z0-9]/g, '-'),
slug: slugify(categoryName),
created_at: new Date()
}).returning('id');
+6 -2
View File
@@ -56,7 +56,9 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
const { name, slug, is_global = true, event_id = null } = req.body;
// Generate slug if not provided
const categorySlug = slug || name.toLowerCase()
const categorySlug = slug || name
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
@@ -128,7 +130,9 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
const updateData = {
name,
slug: name.toLowerCase()
slug: name
.normalize('NFD').replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
+17 -7
View File
@@ -2,6 +2,7 @@ const express = require('express');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { slugify } = require('../utils/slug');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const router = express.Router();
@@ -439,7 +440,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
allow_presigned_download = false,
require_password: requirePasswordInput,
// Feedback settings
feedback_enabled = false,
feedback_enabled: feedbackEnabledInput,
allow_ratings = true,
allow_likes = true,
allow_comments = true,
@@ -507,6 +508,17 @@ router.post('/', adminAuth, requirePermission('events.create'), [
}
const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback);
// Default feedback_enabled from global "event_default_feedback_enabled"
// setting when the body omits it (#520 — same pattern as require_password
// above, lets admins make Guest Feedback ON the out-of-box default for
// new events instead of toggling it on every time).
let feedbackEnabledFallback = false;
if (feedbackEnabledInput === undefined) {
const setting = await readBooleanSetting('event_default_feedback_enabled');
if (setting !== undefined) feedbackEnabledFallback = setting;
}
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
// Debug logging
logger.debug('Download control values', {
allow_downloads,
@@ -538,12 +550,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
}
}
// Generate unique slug
const processedEventName = event_name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
.replace(/-+/g, '-') // Replace multiple dashes with single dash
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
// Generate unique slug. Uses the shared util so accented names
// (Família, Decoração, etc.) get transliterated instead of dropped
// — see backend/src/utils/slug.js for the why (#525).
const processedEventName = slugify(event_name);
// Use event_date in slug if provided, otherwise use random suffix
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
+47 -11
View File
@@ -7,7 +7,11 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { ensureThumbnail } = require('../services/imageProcessor');
const { isVideoMimeType } = require('../services/videoProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { generatePhotoFilename, buildContentDisposition } = require('../utils/filenameSanitizer');
const {
getUseOriginalFilenames,
pickRawDownloadName,
} = require('../services/downloadFilenameService');
const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings');
@@ -222,15 +226,28 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), r
let photoType = 'individual'; // default
let categoryName = 'individual';
// Look up the actual category from database if provided
// Look up the actual category from database if provided. Scope the
// lookup to (event_id = event.id OR is_global = true) — same contract
// the public v1 upload route enforces (#500 / #525). Without it, the
// admin upload silently accepts any category id including ones that
// belong to a different event. The v1 route rejects out-of-scope ids
// with 400; mirror that here so admin and v1 stay consistent.
if (parsedCategoryId && !isNaN(parsedCategoryId)) {
const category = await db('photo_categories').where({ id: parsedCategoryId }).first();
if (category) {
categoryName = category.slug || category.name.toLowerCase().replace(/\s+/g, '_');
// Use category slug for type determination
if (category.slug === 'collage' || category.slug === 'collages') {
photoType = 'collage';
}
const category = await db('photo_categories')
.where({ id: parsedCategoryId })
.andWhere(function () {
this.where({ event_id: event.id }).orWhere('is_global', true);
})
.first();
if (!category) {
return res.status(400).json({
error: `Unknown or out-of-scope category_id ${parsedCategoryId}`
});
}
categoryName = category.slug || category.name.toLowerCase().replace(/\s+/g, '_');
// Use category slug for type determination
if (category.slug === 'collage' || category.slug === 'collages') {
photoType = 'collage';
}
} else if (category_id === 'collage') {
// For backwards compatibility, accept string values
@@ -649,6 +666,12 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
if (photo.hero_path) {
await storage.delete(photo.hero_path).catch(() => {});
}
// Lightbox preview tier (#492). Same disposable-derived semantics
// as thumbnail / hero — wipe on photo delete so we don't leak
// orphaned files into previews/ that no DB row references.
if (photo.preview_path) {
await storage.delete(photo.preview_path).catch(() => {});
}
// Delete pre-generated watermark if exists
if (photo.watermark_path) {
@@ -783,6 +806,10 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
if (photo.hero_path) {
await storage.delete(photo.hero_path).catch(() => {});
}
// Lightbox preview tier (#492) — bulk delete cleanup.
if (photo.preview_path) {
await storage.delete(photo.preview_path).catch(() => {});
}
if (photo.watermark_path) {
await watermarkGeneratorService.deleteForPhoto(photo.id);
}
@@ -901,6 +928,11 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
const storage = getStorage();
const storageKey = resolvePhotoStorageKey(event, photo);
// #493: respect the original-filenames toggle for admin downloads too.
const useOriginal = await getUseOriginalFilenames();
const downloadName = pickRawDownloadName(photo, useOriginal);
const contentDisposition = buildContentDisposition(downloadName);
if (storageKey) {
const stat = await storage.stat(storageKey);
if (!stat) {
@@ -909,7 +941,7 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
res.set({
'Content-Type': photo.mime_type || 'application/octet-stream',
'Content-Length': stat.size,
'Content-Disposition': `attachment; filename="${photo.filename}"`,
'Content-Disposition': contentDisposition,
});
const stream = await storage.get(storageKey);
stream.pipe(res);
@@ -923,7 +955,11 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
} catch (error) {
return res.status(404).json({ error: 'Photo file not found' });
}
res.download(filePath, photo.filename);
res.set({
'Content-Type': photo.mime_type || 'application/octet-stream',
'Content-Disposition': contentDisposition,
});
res.sendFile(filePath);
} catch (error) {
console.error('Error downloading photo:', error);
res.status(500).json({ error: 'Failed to download photo' });
+26 -2
View File
@@ -322,7 +322,8 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
twitter_url,
youtube_url,
promo_markdown,
promo_position
promo_position,
promo_alignment
} = req.body;
// Normalize force_color_mode: only 'dark' | 'light' | null are valid.
@@ -340,6 +341,15 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
? 'below_footer'
: 'above_footer';
// Normalize promo_alignment: 'left' | 'center' | 'right'. Defaults
// to 'center' to match the gallery footer's full-width centering
// (#482 — the previous default left the markdown left-aligned in
// a max-w-3xl block, which read as visually offset from the footer).
const allowedPromoAlignments = ['left', 'center', 'right'];
const normalizedPromoAlignment = allowedPromoAlignments.includes(promo_alignment)
? promo_alignment
: 'center';
// Normalize login_logo_size to the same token set as logo_size.
// Anything else falls back to 'medium' on the next render.
const allowedLoginLogoSizes = ['small', 'medium', 'large', 'xlarge'];
@@ -381,7 +391,8 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
...(twitter_url !== undefined && { twitter_url: String(twitter_url || '').trim() }),
...(youtube_url !== undefined && { youtube_url: String(youtube_url || '').trim() }),
...(promo_markdown !== undefined && { promo_markdown: typeof promo_markdown === 'string' ? promo_markdown : '' }),
...(promo_position !== undefined && { promo_position: normalizedPromoPosition })
...(promo_position !== undefined && { promo_position: normalizedPromoPosition }),
...(promo_alignment !== undefined && { promo_alignment: normalizedPromoAlignment })
};
// Handle favicon deletion if empty string or null is provided
@@ -788,6 +799,19 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
clearShareLinkSettingsCache();
}
// Toggling the original-filenames setting (#493) requires busting the
// per-event pre-generated zips so the next download-all rebuilds with the
// new entry names. Single-photo downloads pick up the change as soon as
// the in-memory cache TTL in downloadFilenameService expires (cleared
// here for immediacy).
if (Object.prototype.hasOwnProperty.call(settings, 'general_use_original_filenames_for_downloads')) {
try {
require('../services/downloadFilenameService').clearCache();
require('../services/downloadZipService').invalidateAll();
} catch (e) {
console.warn('Failed to invalidate download caches after filename setting change:', e.message);
}
}
// Log activity
await db('activity_logs').insert({
+79 -4
View File
@@ -3,7 +3,7 @@ const router = express.Router();
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail } = require('../services/imageProcessor');
const { generateThumbnail, ensurePreviewImage } = require('../services/imageProcessor');
const path = require('path');
const fs = require('fs').promises;
const logger = require('../utils/logger');
@@ -25,7 +25,9 @@ router.get('/settings', adminAuth, requirePermission('photos.view'), async (req,
'thumbnail_height',
'thumbnail_fit',
'thumbnail_quality',
'thumbnail_format'
'thumbnail_format',
// Lightbox preview tier (#492). Boolean, default false.
'lightbox_preview_enabled'
])
.select('setting_key', 'setting_value');
@@ -51,7 +53,7 @@ router.get('/settings', adminAuth, requirePermission('photos.view'), async (req,
// Update thumbnail settings
router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { width, height, fit, quality, format } = req.body;
const { width, height, fit, quality, format, lightbox_preview_enabled } = req.body;
// Validate inputs
if (width && (width < 50 || width > 1000)) {
@@ -77,14 +79,35 @@ router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req,
if (fit) updates.push({ setting_key: 'thumbnail_fit', setting_value: JSON.stringify(fit) });
if (quality) updates.push({ setting_key: 'thumbnail_quality', setting_value: quality });
if (format) updates.push({ setting_key: 'thumbnail_format', setting_value: JSON.stringify(format) });
// Lightbox preview tier (#492). Boolean — store JSON-stringified
// so the round-trip matches what migration 104 seeds.
if (typeof lightbox_preview_enabled === 'boolean') {
updates.push({
setting_key: 'lightbox_preview_enabled',
setting_value: JSON.stringify(lightbox_preview_enabled),
});
}
for (const update of updates) {
await db('app_settings')
const updated = await db('app_settings')
.where('setting_key', update.setting_key)
.update({
setting_value: update.setting_value,
updated_at: db.fn.now()
});
// Defensive insert when the row is missing — covers the case
// where lightbox_preview_enabled is being saved on an install
// that pre-dates migration 104. Existing thumbnail_* keys are
// seeded by migration 040 so the update path always wins for
// them; this only fires on the new key.
if (!updated) {
await db('app_settings').insert({
setting_key: update.setting_key,
setting_value: update.setting_value,
setting_type: update.setting_key === 'lightbox_preview_enabled' ? 'thumbnail' : 'thumbnail',
updated_at: db.fn.now(),
});
}
}
res.json({
@@ -170,6 +193,58 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
}
});
// Regenerate all preview-tier images (#492). Eager backfill counterpart
// to ensurePreviewImage's lazy generation. Mirrors the regenerate
// (thumbnails) endpoint above — same auth, same fire-and-forget shape,
// same per-photo error handling.
router.post('/regenerate-previews', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
const { eventId } = req.body;
let query = db('photos').select('id', 'event_id', 'path', 'media_type', 'mime_type', 'preview_path');
if (eventId) query = query.where('event_id', eventId);
// Skip videos — preview tier is image-only.
query = query.where(function() {
this.whereNull('media_type').orWhere('media_type', '!=', 'video');
});
const photos = await query;
if (photos.length === 0) {
return res.json({ message: 'No image photos to regenerate previews for', count: 0 });
}
res.json({
message: `Started regenerating ${photos.length} previews`,
count: photos.length,
});
setImmediate(async () => {
let successCount = 0;
let errorCount = 0;
for (const photo of photos) {
try {
// Force regeneration regardless of existing preview state by
// nulling the cached path so ensurePreviewImage doesn't
// short-circuit on a stale isPreviewValid check.
const newPreviewPath = await ensurePreviewImage({ ...photo, preview_path: null });
if (newPreviewPath) {
successCount++;
} else {
errorCount++;
}
} catch (error) {
logger.error(`Error regenerating preview for photo ${photo.id}:`, error);
errorCount++;
}
}
logger.info(`Preview regeneration complete: ${successCount} success, ${errorCount} errors`);
});
} catch (error) {
logger.error('Error starting preview regeneration:', error);
res.status(500).json({ error: 'Failed to start preview regeneration' });
}
});
// Get regeneration status
router.get('/regenerate/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
+39 -5
View File
@@ -11,6 +11,37 @@ const { handleAsync, validateRequest, successResponse } = require('../utils/rout
const userManagementService = require('../services/userManagementService');
const router = express.Router();
/**
* Coerce any of the shapes a TIMESTAMP column produces across our
* supported drivers into a single ISO 8601 string the frontend (and
* any external API consumer) can safely pass to date-fns / new Date.
*
* Postgres → Date object (becomes ISO via JSON.stringify anyway, but
* pinning the format defends against driver-side surprises).
* SQLite → integer milliseconds since epoch (the surface that crashed
* the admin Users page in #485 — `parseISO(123456789)` blows up
* with "e.split is not a function"). Native installs default to
* SQLite, so this path matters every release.
* Already a string → assume it's a parseable ISO/RFC3339 (Postgres
* driver may stringify under JSON serialization mid-pipeline).
*
* Returns null/undefined unchanged so an unset last_login surfaces as
* "Never" in the UI rather than 1970-01-01T00:00:00Z.
*/
function toIso(value) {
if (value === null || value === undefined || value === '') return value;
if (value instanceof Date) return value.toISOString();
if (typeof value === 'number') return new Date(value).toISOString();
if (typeof value === 'string') {
// Numeric-as-string ("1778752458666") happens when the SQLite
// driver stringifies large integers — re-coerce so the frontend
// doesn't try to parseISO('1778752458666').
if (/^\d{10,}$/.test(value)) return new Date(Number(value)).toISOString();
return value;
}
return value;
}
/**
* Transform user object from snake_case (DB) to camelCase (API)
*/
@@ -20,10 +51,10 @@ function transformUser(user) {
username: user.username,
email: user.email,
isActive: user.is_active,
lastLogin: user.last_login,
lastLogin: toIso(user.last_login),
lastLoginIp: user.last_login_ip,
createdAt: user.created_at,
updatedAt: user.updated_at,
createdAt: toIso(user.created_at),
updatedAt: toIso(user.updated_at),
roleId: user.role_id,
roleName: user.role_name,
roleDisplayName: user.role_display_name,
@@ -52,8 +83,8 @@ function transformInvitation(invitation) {
return {
id: invitation.id,
email: invitation.email,
expiresAt: invitation.expires_at,
createdAt: invitation.created_at,
expiresAt: toIso(invitation.expires_at),
createdAt: toIso(invitation.created_at),
roleName: invitation.role_name,
invitedBy: invitation.invited_by
};
@@ -212,4 +243,7 @@ router.post('/:id/reset-password', [
successResponse(res, { message: 'Password reset email sent', ...result });
}));
// Test surface: expose the date normaliser so the unit test can pin
// the contract without spinning up the full router.
module.exports = router;
module.exports.__test = { toIso, transformUser, transformInvitation };
+3 -2
View File
@@ -4,6 +4,7 @@ const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { slugify } = require('../utils/slug');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { adminAuth } = require('../middleware/auth');
const fs = require('fs').promises;
@@ -125,8 +126,8 @@ router.post('/', adminAuth, [
}
}
// Generate unique slug
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
// Generate unique slug — slugify() handles accents (see #525).
const baseSlug = `${event_type}-${slugify(event_name)}-${event_date}`;
let slug = baseSlug;
let counter = 1;
+183 -9
View File
@@ -13,8 +13,14 @@ const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
const { ensureThumbnail, ensureHeroImage, withLocalCopy } = require('../services/imageProcessor');
const { ensureThumbnail, ensureHeroImage, ensurePreviewImage, withLocalCopy } = require('../services/imageProcessor');
const downloadZipService = require('../services/downloadZipService');
const {
getUseOriginalFilenames,
pickRawDownloadName,
getZipEntryNames,
} = require('../services/downloadFilenameService');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const { getStorage } = require('../services/storage');
const fs = require('fs');
@@ -399,6 +405,38 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
fragmentation_level: req.event.fragmentation_level || 3,
overlay_protection: req.event.overlay_protection !== false
};
// Lightbox preview tier (#492). When the admin opts in, the
// photos response carries a preview_url alongside url/thumbnail_url
// — the lightbox uses preview_url when present and falls back to
// url when not, so existing galleries continue working before
// any preview has actually been generated.
let lightboxPreviewEnabled = false;
try {
const setting = await db('app_settings')
.where('setting_key', 'lightbox_preview_enabled')
.first();
if (setting) {
const raw = setting.setting_value;
// setting_value is JSON-stringified per migration 104; tolerate
// raw boolean/string for forward-compat.
const parsed = typeof raw === 'string' ? (() => {
try { return JSON.parse(raw); } catch { return raw; }
})() : raw;
lightboxPreviewEnabled = parsed === true || parsed === 'true' || parsed === 1;
}
} catch (e) {
// Setting missing / DB blip → fall back to off so the lightbox
// keeps working with the original. logger.debug to avoid noise.
logger.debug('lightbox_preview_enabled lookup failed, treating as off', { error: e?.message });
}
// #508: when the admin has flipped the "use original camera filenames"
// toggle (#493), the lightbox surfaces each photo's original_filename
// alongside the position counter so the photographer can map a guest's
// selection back to source files. Tied to the same toggle as downloads —
// one switch controls both surfaces.
const useOriginalFilenames = await getUseOriginalFilenames();
res.json({
@@ -427,6 +465,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
hero_image_anchor: req.event.hero_image_anchor || 'center',
default_photo_sort: req.event.default_photo_sort || 'upload_date_desc',
download_zip_ready: !!(req.event.download_zip_path && req.event.download_zip_generated_at),
// Mirror of the admin-side toggle so the lightbox can decide
// whether to surface original camera filenames (#508).
use_original_filenames: useOriginalFilenames,
...protectionSettings
},
categories: categories,
@@ -441,10 +482,24 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
return {
id: photo.id,
filename: photo.filename,
// Raw camera filename (or null for pre-migration-062 uploads).
// The lightbox renders it when `use_original_filenames` is on.
original_filename: photo.original_filename || null,
url: photoUrl,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null,
// Hero-optimized image URL (1920x1080) for full-width hero sections
hero_url: `/api/gallery/${req.params.slug}/hero/${photo.id}${wmQuery}`,
// Lightbox preview URL (#492). Only emitted when the admin
// has flipped lightbox_preview_enabled — the frontend
// lightbox reads preview_url with a fallback to url so
// installs that haven't opted in keep loading the original
// (current behaviour). Skipped for videos since they don't
// get a preview tier; lightbox will use the original .url.
preview_url: lightboxPreviewEnabled
&& photo.media_type !== 'video'
&& (!photo.mime_type || !photo.mime_type.startsWith('video/'))
? `/api/gallery/${req.params.slug}/preview/${photo.id}${wmQuery}`
: null,
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type,
@@ -596,6 +651,13 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
const eventWatermarkEnabled = req.event.watermark_downloads === true || req.event.watermark_downloads === 1;
const shouldApplyWatermark = (watermarkSettings && watermarkSettings.enabled) || eventWatermarkEnabled;
// #493: if the admin enabled "use original filenames", surface the
// pre-rename camera filename in Content-Disposition. Storage path is
// unchanged — only the user-visible download name is swapped.
const useOriginal = await getUseOriginalFilenames();
const downloadName = pickRawDownloadName(photo, useOriginal);
const contentDisposition = buildContentDisposition(downloadName);
if (shouldApplyWatermark) {
// Apply watermark and send
// Use event watermark text if available, otherwise fall back to global settings
@@ -608,14 +670,21 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Disposition': `attachment; filename="${photo.filename}"`,
'Content-Disposition': contentDisposition,
'Content-Length': watermarkedBuffer.length
});
res.send(watermarkedBuffer);
} else {
// Send original file
res.download(filePath, photo.filename, (downloadError) => {
// res.download() builds Content-Disposition itself but doesn't emit the
// RFC 5987 filename* parameter, so unicode camera filenames would lose
// their bytes on download. Set the header explicitly and stream the
// file with res.sendFile-equivalent semantics.
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Disposition': contentDisposition,
});
res.sendFile(filePath, (downloadError) => {
if (downloadError) {
logger.error('Error streaming gallery download', {
slug: req.params.slug,
@@ -737,14 +806,20 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
// Add photos to archive — managed photos via storage backend, external via local path.
const { resolvePhotoStorageKey } = require('../services/photoResolver');
const storage = getStorage();
for (const photo of photos) {
// #493: resolve a unique display filename per photo up-front so collisions
// get a deterministic `_1` suffix before the entries hit the archive.
const useOriginalBulk = await getUseOriginalFilenames();
const bulkEntryNames = getZipEntryNames(photos, useOriginalBulk);
for (let i = 0; i < photos.length; i += 1) {
const photo = photos[i];
const storageKey = resolvePhotoStorageKey(req.event, photo);
const entryName = bulkEntryNames[i];
let archiveName;
if (hasMultipleTypes) {
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
archiveName = path.join(folderName, photo.filename);
archiveName = path.join(folderName, entryName);
} else {
archiveName = photo.filename;
archiveName = entryName;
}
try {
@@ -865,8 +940,12 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
const { resolvePhotoStorageKey: resolveSelectedKey } = require('../services/photoResolver');
const { withLocalCopy: withSelectedLocalCopy } = require('../services/imageProcessor');
const selectedStorage = getStorage();
for (const photo of photos) {
const name = photo.filename || `photo-${photo.id}.jpg`;
// #493: same display-name resolution as bulk download, with dedup.
const useOriginalSelected = await getUseOriginalFilenames();
const selectedEntryNames = getZipEntryNames(photos, useOriginalSelected);
for (let i = 0; i < photos.length; i += 1) {
const photo = photos[i];
const name = selectedEntryNames[i] || `photo-${photo.id}.jpg`;
const storageKey = resolveSelectedKey(req.event, photo);
try {
if (shouldApplyWatermark && effectiveSettings) {
@@ -1337,6 +1416,101 @@ router.get('/:slug/hero/:photoId',
}
);
// Lightbox preview tier (#492). Aspect-preserved JPEG capped at 1920px
// long edge — admin-controlled opt-in via app_settings.lightbox_preview_enabled.
// Mirrors the hero route shape: same auth, ETag from preview mtime,
// fall back to original on any failure so the lightbox never shows a
// broken image. The watermark application path is preserved so a
// preview surfaced in the lightbox carries the same protection a
// guest would see on the full original.
router.get('/:slug/preview/:photoId',
verifyGalleryAccess,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Videos don't get a preview tier — fall through to the regular
// photo endpoint (which serves the source). The frontend should
// already be checking media_type before requesting /preview but
// belt-and-braces in case a stale tab does.
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
if (isVideo) {
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
}
// Lazy generation: ensurePreviewImage returns null on any
// failure (corrupt source, sharp OOM, storage unavailable, …).
// Fall back to the original so the lightbox always renders.
const previewPath = await ensurePreviewImage(photo);
if (!previewPath) {
logger.warn(`Failed to generate preview for photo ${photoId}, falling back to original`);
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
}
const storage = getStorage();
const stat = await storage.stat(previewPath);
if (!stat) {
logger.error('Preview file does not exist in storage backend', {
slug: req.params.slug, photoId, eventId: req.event.id, previewPath,
});
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
}
const mtimeMs = stat.mtime ? stat.mtime.getTime() : 0;
const watermarkSettings = await watermarkService.getWatermarkSettings();
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
const etag = `"preview-${photoId}-${mtimeMs}${watermarkHash}"`;
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
res.set({
'Content-Type': 'image/jpeg',
// Cache aggressively — preview only changes on photo
// re-upload (which generates a new preview key) or settings
// regenerate (which writes a new mtime + ETag).
'Cache-Control': 'private, max-age=3600',
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
'X-Preview-Image': 'true',
'ETag': etag,
});
if (watermarkSettings && watermarkSettings.enabled) {
const watermarkedBuffer = await withLocalCopy(previewPath, (localPath) =>
watermarkService.applyWatermark(localPath, watermarkSettings)
);
res.send(watermarkedBuffer);
} else {
res.setHeader('Content-Length', stat.size);
const stream = await storage.get(previewPath);
stream.pipe(res);
}
} catch (error) {
logger.error('Error serving preview image:', {
error: error.message,
photoId: req.params.photoId,
eventId: req.event?.id,
});
res.redirect(`/api/gallery/${req.params.slug}/photo/${req.params.photoId}`);
}
}
);
// Get feedback settings for gallery
router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => {
try {
+11
View File
@@ -16,6 +16,7 @@ router.get('/', async (req, res) => {
.orWhereIn('setting_key', [
'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai',
'event_default_require_password',
'event_default_feedback_enabled',
'gallery_show_filter_bar',
'event_phone_field_enabled'
]);
@@ -77,6 +78,12 @@ router.get('/', async (req, res) => {
branding_promo_position: settingsObject.branding_promo_position === 'below_footer'
? 'below_footer'
: 'above_footer',
// Promo content alignment (#482). Defaults to center so the
// banner aligns with the gallery footer; admin can flip to
// left or right via Settings → Branding.
branding_promo_alignment: ['left', 'center', 'right'].includes(settingsObject.branding_promo_alignment)
? settingsObject.branding_promo_alignment
: 'center',
// Force a specific color mode site-wide. When set, the user toggle
// is hidden and the value overrides per-theme/system preference.
// Allowed values: 'dark' | 'light' | null (null = no force).
@@ -114,6 +121,10 @@ router.get('/', async (req, res) => {
event_require_expiration: settingsObject.event_require_expiration !== false,
// Default value for "Require password" toggle in event creation form
event_default_require_password: settingsObject.event_default_require_password !== false,
// Default value for the "Guest Feedback enabled" toggle (#520).
// Defaults to false (matches the prior hard-coded form default), so
// existing installs see no behaviour change until an admin flips it.
event_default_feedback_enabled: settingsObject.event_default_feedback_enabled === true,
// Phone-number field on events is opt-in (#322).
event_phone_field_enabled: settingsObject.event_phone_field_enabled === true,
// Whether to show the search/sort filter bar in public galleries (default: true)
+13 -1
View File
@@ -8,6 +8,11 @@ const { formatBoolean } = require('../utils/dbCompat');
const { resolvePhotoFilePath, resolvePhotoStorageKey } = require('../services/photoResolver');
const { withLocalCopy } = require('../services/imageProcessor');
const { getStorage } = require('../services/storage');
const {
getUseOriginalFilenames,
pickRawDownloadName,
} = require('../services/downloadFilenameService');
const { buildContentDisposition } = require('../utils/filenameSanitizer');
const router = express.Router();
@@ -339,9 +344,16 @@ router.get('/:slug/secure-download/:photoId/:token',
'download'
);
// #493/#507: respect the original-filename toggle here too. The
// regular `/gallery/:slug/download/:photoId` route already does
// this — secure-images was missed in the original PR and ran
// even when the admin had opted into original camera filenames.
const useOriginal = await getUseOriginalFilenames();
const downloadName = pickRawDownloadName(photo, useOriginal);
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Content-Disposition': `attachment; filename="${photo.filename}"`,
'Content-Disposition': buildContentDisposition(downloadName),
'Content-Length': fileBuffer.length,
'X-Download-Protected': 'true'
});
@@ -0,0 +1,262 @@
/**
* Regression test for PR the_luap/picpeak#500.
*
* The v1 upload endpoint POST /events/:id/photos used to accept any
* photo_categories.id, including ones belonging to a different event.
* apiTokenAuth has no per-event scoping, so this let a programmatic
* uploader silently mis-file photos under a category that doesn't
* belong to the target event.
*
* The fix scopes the lookup to (event_id = event.id OR is_global = true)
* — see backend/migrations/legacy/004_add_categories_and_cms.js for the
* photo_categories columns. These tests verify both that the scoping
* clause is exactly that, and that the 400 response carries the new
* "Unknown or out-of-scope category_id" error string.
*
* Pattern lifted from src/routes/__tests__/adminAuth.test.js.
*/
const request = require('supertest');
const express = require('express');
const buildChain = ({ firstResult, insertResult } = {}) => ({
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
first: jest.fn().mockResolvedValue(firstResult),
insert: jest.fn().mockResolvedValue(insertResult ?? [1]),
});
jest.mock('../../../database/db', () => {
const dbMock = jest.fn();
dbMock.raw = jest.fn();
dbMock.__setImplementations = (...chains) => {
dbMock.mockReset();
chains.forEach((chain) => {
dbMock.mockImplementationOnce(() => chain);
});
};
return {
db: dbMock,
logActivity: jest.fn().mockResolvedValue(undefined),
};
});
jest.mock('../../../middleware/apiTokenAuth', () => ({
apiTokenAuth: (req, _res, next) => {
req.apiToken = { id: 1, admin_id: 1, scopes: ['write'] };
req.admin = { id: 1, username: 'token-admin' };
next();
},
requireApiScope: () => (_req, _res, next) => next(),
}));
// photoUpload is built inside events.js (multer({...})), not imported
// from a shared module. Mock the multer factory so .single(field)
// returns middleware that injects a stub req.file synchronously.
//
// Caveat: `path` points at a file that doesn't exist on disk. The
// current 400-path tests short-circuit before the handler touches the
// filesystem. Any future test that exercises a happy-path category
// match must either create the file under beforeAll() or stub the
// `fs`/`fsSync` modules — otherwise `fsSync.statSync(tempPath)` will
// throw and the test will surface a misleading 500.
jest.mock('multer', () => {
const fakeUpload = {
single: () => (req, _res, next) => {
req.file = {
path: '/tmp/fake-v1-upload.jpg',
originalname: 'fake.jpg',
size: 1,
mimetype: 'image/jpeg',
};
next();
},
};
const factory = jest.fn(() => fakeUpload);
factory.diskStorage = jest.fn(() => ({}));
return factory;
});
// Stub sharp so the happy-path test doesn't actually decode an image
// (the temp file is a 0-byte placeholder — see the beforeAll below).
jest.mock('sharp', () => jest.fn(() => ({
metadata: jest.fn().mockResolvedValue({ width: 1920, height: 1080 }),
})));
// Thumbnail + storage are network/fs-heavy; stub to constant resolves
// so the test stays a pure unit test of the route handler's contract.
jest.mock('../../../services/imageProcessor', () => ({
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/fake_thumb.jpg'),
}));
jest.mock('../../../services/storage', () => ({
getStorage: jest.fn(() => ({
putFromFile: jest.fn().mockResolvedValue(undefined),
})),
}));
// webhookService.fire is wrapped in try/catch in the route, so a
// missing mock would still let the test pass — but stubbing it
// silences the predictable failure log so the test output stays clean.
jest.mock('../../../services/webhookService', () => ({
fire: jest.fn().mockResolvedValue(undefined),
}));
const fsSync = require('fs');
const { db } = require('../../../database/db');
const eventsRouter = require('../events');
const buildApp = () => {
const app = express();
app.use(express.json());
app.use('/', eventsRouter);
return app;
};
describe('v1 POST /events/:id/photos — category scoping', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('scopes the category lookup to event-owned or global rows', async () => {
const eventChain = buildChain({ firstResult: { id: 42, slug: 'wedding-2026' } });
const categoryChain = buildChain({ firstResult: null });
db.__setImplementations(eventChain, categoryChain);
// Use JSON body (express.json parses it before the mocked multer
// middleware runs). The route reads req.body.category_id either
// way — multer would have parsed the field as a string, json sends
// a string too.
// .expect(400) also pins the response status — without it a future
// regression that swallowed the error and returned 500 would still
// satisfy the scoping-call assertions below.
await request(buildApp())
.post('/events/42/photos')
.send({ category_id: '7' })
.expect(400);
// The category lookup chain receives the id filter…
expect(categoryChain.where).toHaveBeenCalledWith({ id: 7 });
// …and a single andWhere() with the scoping callback.
expect(categoryChain.andWhere).toHaveBeenCalledTimes(1);
const scopingCb = categoryChain.andWhere.mock.calls[0][0];
expect(typeof scopingCb).toBe('function');
// Invoke the callback against a knex-shaped builder spy and verify
// the OR-clause it builds: event_id = 42 OR is_global = true.
const builderSpy = {
where: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
};
scopingCb.call(builderSpy);
expect(builderSpy.where).toHaveBeenCalledWith({ event_id: 42 });
expect(builderSpy.orWhere).toHaveBeenCalledWith('is_global', true);
});
it('returns 400 with out-of-scope error when no category row matches', async () => {
db.__setImplementations(
buildChain({ firstResult: { id: 42, slug: 'wedding-2026' } }),
buildChain({ firstResult: null }),
);
const response = await request(buildApp())
.post('/events/42/photos')
.send({ category_id: '7' })
.expect(400);
expect(response.body).toEqual({
error: 'Unknown or out-of-scope category_id 7',
});
});
});
describe('v1 POST /events/:id/photos — happy path (#525)', () => {
const FAKE_TMP = '/tmp/fake-v1-upload.jpg';
beforeEach(() => {
jest.clearAllMocks();
// Recreate the temp file on every test — the handler calls
// fs.unlink(tempPath) after a successful upload, so a beforeAll
// would leave the second test without an inode for statSync to
// read (manifests as 500 Internal Server Error).
fsSync.writeFileSync(FAKE_TMP, '');
});
afterAll(() => {
try { fsSync.unlinkSync(FAKE_TMP); } catch { /* may have been unlinked by the handler */ }
});
it('inserts the photo and returns 201 with the resolved category_id', async () => {
// Three db() calls in sequence on the happy path:
// 1. events lookup
// 2. photo_categories lookup (returns a valid in-scope row)
// 3. photos insert returning the new id
const eventChain = buildChain({
firstResult: { id: 42, slug: 'wedding-2026', event_name: 'Wedding 2026' },
});
const categoryChain = buildChain({
firstResult: { id: 7, slug: 'ceremony', name: 'Ceremony', event_id: 42 },
});
const insertChain = {
...buildChain({ insertResult: [{ id: 101 }] }),
returning: jest.fn().mockResolvedValue([{ id: 101 }]),
};
// Override insert so the returning() call is chainable
insertChain.insert = jest.fn(() => insertChain);
db.__setImplementations(eventChain, categoryChain, insertChain);
const response = await request(buildApp())
.post('/events/42/photos')
.send({ category_id: '7' })
.expect(201);
// Response shape pins the v1 API contract — id + category_id are
// the fields the n8n / API-token use case depends on (see #500).
expect(response.body).toMatchObject({
id: 101,
category_id: 7,
size_bytes: 0,
thumbnail_path: 'thumbnails/fake_thumb.jpg',
});
expect(response.body.filename).toMatch(/^\d+_[a-f0-9]+\.jpg$/);
expect(response.body.path).toMatch(/^wedding-2026\/\d+_[a-f0-9]+\.jpg$/);
// The insert payload should carry the resolved category_id and the
// 'individual' photo type (the test category slug isn't 'collage').
const insertedRow = insertChain.insert.mock.calls[0][0];
expect(insertedRow).toMatchObject({
event_id: 42,
category_id: 7,
type: 'individual',
media_type: 'image',
mime_type: 'image/jpeg',
});
});
it('flips photo type to collage when the category slug is "collage"', async () => {
const eventChain = buildChain({
firstResult: { id: 42, slug: 'wedding-2026' },
});
const categoryChain = buildChain({
firstResult: { id: 9, slug: 'collage', name: 'Collage', event_id: 42 },
});
const insertChain = {
...buildChain(),
returning: jest.fn().mockResolvedValue([{ id: 202 }]),
};
insertChain.insert = jest.fn(() => insertChain);
db.__setImplementations(eventChain, categoryChain, insertChain);
await request(buildApp())
.post('/events/42/photos')
.send({ category_id: '9' })
.expect(201);
expect(insertChain.insert.mock.calls[0][0]).toMatchObject({
category_id: 9,
type: 'collage',
});
});
});
@@ -0,0 +1,218 @@
/**
* Regression tests for issue #550.
*
* Two related bugs in POST /v1/events:
* 1. color_theme was not accepted on the request body and never written
* to the events row. Editing such an event later in the admin UI
* snapped the theme picker to GALLERY_THEME_PRESETS.default and
* saving overwrote whatever theme was inherited visually.
* 2. event_feedback_settings row was never created, so the gallery UI
* read it as "feedback off" regardless of the global
* event_default_feedback_enabled toggle (#520).
*
* Test pattern mirrors events.category.test.js — queue up db() chains
* with db.__setImplementations() in the exact order the handler invokes
* them, then assert against the captured payloads.
*/
const request = require('supertest');
const express = require('express');
const buildChain = ({ firstResult, insertResult, returningResult } = {}) => {
const chain = {
where: jest.fn().mockReturnThis(),
andWhere: jest.fn().mockReturnThis(),
orWhere: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
first: jest.fn().mockResolvedValue(firstResult),
insert: jest.fn().mockReturnThis(),
returning: jest.fn().mockResolvedValue(returningResult ?? insertResult ?? [{ id: 1 }]),
};
return chain;
};
jest.mock('../../../database/db', () => {
const dbMock = jest.fn();
dbMock.raw = jest.fn();
dbMock.__setImplementations = (...chains) => {
dbMock.mockReset();
chains.forEach((chain) => {
dbMock.mockImplementationOnce(() => chain);
});
};
return {
db: dbMock,
logActivity: jest.fn().mockResolvedValue(undefined),
};
});
jest.mock('../../../middleware/apiTokenAuth', () => ({
apiTokenAuth: (req, _res, next) => {
req.apiToken = { id: 1, admin_id: 1, scopes: ['admin'] };
req.admin = { id: 1, username: 'token-admin' };
next();
},
requireApiScope: () => (_req, _res, next) => next(),
}));
// bcrypt.hash is awaited twice per request (real path + dummy path).
// Stub it to a constant so tests don't burn CPU on bcrypt rounds.
jest.mock('bcrypt', () => ({
hash: jest.fn().mockResolvedValue('$2b$10$mocked-hash'),
}));
jest.mock('../../../services/shareLinkService', () => ({
buildShareLinkVariants: jest.fn().mockResolvedValue({
shareUrl: 'https://example.test/gallery/some-slug?t=abc',
shareLinkToStore: '/gallery/some-slug?t=abc',
}),
}));
// Webhook fire is in a try/catch; stub to silence the predictable
// failure log so test output stays clean.
jest.mock('../../../services/webhookService', () => ({
fire: jest.fn().mockResolvedValue(undefined),
buildEventSubject: jest.fn().mockReturnValue({}),
}));
const { db } = require('../../../database/db');
const eventsRouter = require('../events');
const buildApp = () => {
const app = express();
app.use(express.json());
app.use('/', eventsRouter);
return app;
};
const BASE_BODY = {
event_name: 'Issue 550 Wedding',
event_type: 'wedding',
event_date: '2026-06-15',
require_password: false,
};
describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('persists color_theme to the events row when provided', async () => {
// db() call sequence for this body (feedback_enabled omitted, no
// customer_phone, no slug collision):
// 1. app_settings.where('event_default_feedback_enabled').first()
// 2. events.where({ slug }).first() ← uniqueness probe
// 3. events.insert(...).returning('id')
// No event_feedback_settings insert because the global setting
// returns nothing (feedback stays off) — covered separately below.
const settingChain = buildChain({ firstResult: null });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 42 }] });
db.__setImplementations(settingChain, slugChain, insertChain);
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, color_theme: 'default' })
.expect(201);
const insertedRow = insertChain.insert.mock.calls[0][0];
expect(insertedRow).toMatchObject({
event_name: 'Issue 550 Wedding',
color_theme: 'default',
});
});
it('accepts a JSON-encoded theme string and persists it verbatim', async () => {
db.__setImplementations(
buildChain({ firstResult: null }),
buildChain({ firstResult: null }),
buildChain({ returningResult: [{ id: 43 }] }),
);
const customTheme = JSON.stringify({ primaryColor: '#ff0066' });
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, color_theme: customTheme })
.expect(201);
const insertedRow = db.mock.results[2].value.insert.mock.calls[0][0];
expect(insertedRow.color_theme).toBe(customTheme);
});
it('creates event_feedback_settings row when feedback_enabled=true is sent', async () => {
// 3 db() calls when feedback_enabled is sent explicitly (the
// settings probe is skipped because feedbackEnabledInput !== undefined):
// 1. slug probe, 2. events insert, 3. feedback insert
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 50 }] });
const feedbackInsertChain = buildChain();
db.__setImplementations(slugChain, insertChain, feedbackInsertChain);
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, feedback_enabled: true })
.expect(201);
// db('event_feedback_settings') is the 3rd invocation.
expect(db).toHaveBeenNthCalledWith(3, 'event_feedback_settings');
const feedbackRow = feedbackInsertChain.insert.mock.calls[0][0];
expect(feedbackRow).toMatchObject({ event_id: 50 });
// formatBoolean() returns 1/0 on SQLite and true/false on PG. Either
// way the value must be truthy/falsy in the right places — assert by
// coercion so the test stays driver-agnostic.
expect(Boolean(feedbackRow.feedback_enabled)).toBe(true);
expect(Boolean(feedbackRow.allow_ratings)).toBe(true);
expect(Boolean(feedbackRow.allow_likes)).toBe(true);
expect(Boolean(feedbackRow.allow_comments)).toBe(true);
expect(Boolean(feedbackRow.allow_favorites)).toBe(true);
expect(Boolean(feedbackRow.require_name_email)).toBe(false);
expect(Boolean(feedbackRow.moderate_comments)).toBe(true);
expect(Boolean(feedbackRow.show_feedback_to_guests)).toBe(true);
});
it('honours the event_default_feedback_enabled global when body omits feedback_enabled', async () => {
// settings probe returns a serialized "true" — fallback should kick
// in and the feedback row should still be written.
const settingChain = buildChain({
firstResult: { setting_key: 'event_default_feedback_enabled', setting_value: 'true' },
});
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 51 }] });
const feedbackInsertChain = buildChain();
db.__setImplementations(settingChain, slugChain, insertChain, feedbackInsertChain);
await request(buildApp())
.post('/events')
.send(BASE_BODY)
.expect(201);
expect(db).toHaveBeenNthCalledWith(4, 'event_feedback_settings');
expect(feedbackInsertChain.insert).toHaveBeenCalledTimes(1);
});
it('does NOT create a feedback row when global setting is unset and body omits feedback_enabled', async () => {
const settingChain = buildChain({ firstResult: null });
const slugChain = buildChain({ firstResult: null });
const insertChain = buildChain({ returningResult: [{ id: 52 }] });
db.__setImplementations(settingChain, slugChain, insertChain);
await request(buildApp())
.post('/events')
.send(BASE_BODY)
.expect(201);
// Only 3 db() calls — the event_feedback_settings table is never
// touched because feedback_enabled resolved to false.
expect(db).toHaveBeenCalledTimes(3);
expect(db).not.toHaveBeenCalledWith('event_feedback_settings');
});
it('rejects non-boolean feedback_enabled with 400', async () => {
// Validators run before any db() call, so no chain queueing needed.
await request(buildApp())
.post('/events')
.send({ ...BASE_BODY, feedback_enabled: 'maybe' })
.expect(400);
});
});
+104 -6
View File
@@ -23,6 +23,9 @@ const { apiTokenAuth, requireApiScope } = require('../../middleware/apiTokenAuth
const { buildShareLinkVariants } = require('../../services/shareLinkService');
const { generateThumbnail } = require('../../services/imageProcessor');
const logger = require('../../utils/logger');
const { slugify } = require('../../utils/slug');
const { formatBoolean } = require('../../utils/dbCompat');
const { parseBooleanInput } = require('../../utils/parsers');
const router = express.Router();
@@ -51,8 +54,8 @@ const photoUpload = multer({
}
});
const slugify = (s) =>
String(s).toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '');
// slugify now imported from ../../utils/slug — shared with adminEvents
// and events.js so the diacritic fix from #502 lands here too (#525).
// ──────────────────────────────────────────────────────────────────────────
// POST /events — create event
@@ -86,6 +89,8 @@ const slugify = (s) =>
* require_password: { type: boolean, default: true }
* password: { type: string, nullable: true, description: "Required when require_password is true." }
* expires_at: { type: string, format: date-time, nullable: true }
* color_theme: { type: string, nullable: true, description: "Preset name (e.g. 'default') or JSON-encoded ThemeConfig. Persisted as-is on the event row." }
* feedback_enabled: { type: boolean, nullable: true, description: "Enable guest feedback for this gallery. When omitted, falls back to the global event_default_feedback_enabled setting." }
* responses:
* 201:
* description: Event created
@@ -116,7 +121,9 @@ router.post(
body('admin_email').optional({ nullable: true, checkFalsy: true }).isEmail(),
body('require_password').optional().isBoolean(),
body('password').optional({ nullable: true }).isString().isLength({ min: 6 }),
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601()
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
body('color_theme').optional({ nullable: true }).isString().trim(),
body('feedback_enabled').optional().isBoolean()
],
async (req, res) => {
try {
@@ -126,9 +133,28 @@ router.post(
event_name, event_type, event_date,
customer_name = null, customer_email = null, customer_phone = null,
admin_email = null, require_password = true, password,
expires_at = null
expires_at = null,
color_theme = null,
feedback_enabled: feedbackEnabledInput
} = req.body;
// Issue #550 — mirror the admin POST path so API-created events
// pick up the global "Enable Guest Feedback by default" toggle
// (event_default_feedback_enabled). Without this, the UI reads
// a missing event_feedback_settings row as "feedback off"
// regardless of the admin's chosen default.
let feedbackEnabledFallback = false;
if (feedbackEnabledInput === undefined) {
const setting = await db('app_settings').where('setting_key', 'event_default_feedback_enabled').first();
if (setting) {
try {
const parsed = JSON.parse(setting.setting_value);
if (typeof parsed === 'boolean') feedbackEnabledFallback = parsed;
} catch { /* keep false */ }
}
}
const feedback_enabled = parseBooleanInput(feedbackEnabledInput, feedbackEnabledFallback);
if (require_password && (!password || password.length < 6)) {
return res.status(400).json({ error: 'Password is required when require_password is true (min 6 chars)' });
}
@@ -173,12 +199,36 @@ router.post(
created_at: new Date().toISOString(),
created_by: req.admin.id,
is_draft: false,
// Issue #550 — without this, editing an API-created event in the
// admin UI snaps the theme picker to GALLERY_THEME_PRESETS.default
// and saving overwrites whatever theme was inherited visually.
color_theme,
...(customer_name ? { customer_name } : {}),
...(customer_email ? { customer_email } : {}),
...(persistPhone ? { customer_phone: persistPhone } : {})
}).returning('id');
const id = insertResult[0]?.id || insertResult[0];
// Issue #550 — mirror adminEvents.js: create event_feedback_settings
// row when feedback is enabled, so the gallery actually shows
// feedback UI. Sub-flags default to the same values the admin form
// ships with (everything on except require_name_email).
if (feedback_enabled) {
await db('event_feedback_settings').insert({
event_id: id,
feedback_enabled: formatBoolean(true),
allow_ratings: formatBoolean(true),
allow_likes: formatBoolean(true),
allow_comments: formatBoolean(true),
allow_favorites: formatBoolean(true),
require_name_email: formatBoolean(false),
moderate_comments: formatBoolean(true),
show_feedback_to_guests: formatBoolean(true),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
}
await logActivity('event_created', { via: 'api_v1', event_type }, id, {
type: 'admin', id: req.admin.id, name: req.admin.username
});
@@ -338,6 +388,13 @@ router.get('/events/:id', apiTokenAuth, requireApiScope('read'), async (req, res
* required: [photo]
* properties:
* photo: { type: string, format: binary }
* category_id:
* type: integer
* description: |
* Optional. If provided, the photo is filed under the
* given photo_categories.id (must belong to the event
* or be a global category). If omitted, the photo
* lands uncategorized.
* responses:
* 201:
* description: Photo uploaded
@@ -351,6 +408,7 @@ router.get('/events/:id', apiTokenAuth, requireApiScope('read'), async (req, res
* path: { type: string }
* thumbnail_path: { type: string, nullable: true }
* size_bytes: { type: integer }
* category_id: { type: integer, nullable: true }
* 400: { description: No file or invalid type }
* 404: { description: Event not found }
*/
@@ -368,6 +426,38 @@ router.post(
const event = await db('events').where({ id: req.params.id }).first();
if (!event) return res.status(404).json({ error: 'Event not found' });
// Optional category assignment, mirroring the admin upload route
// (adminPhotos.js). Multipart form field `category_id`. If the
// category looks up to a "collage" slug, the photo's `type` flips
// accordingly so existing collage-aware UI paths still work.
const rawCategoryId = req.body?.category_id;
const parsedCategoryId = rawCategoryId ? parseInt(rawCategoryId, 10) : NaN;
let categoryId = null;
let photoType = 'individual';
if (!Number.isNaN(parsedCategoryId)) {
// Scope to categories owned by this event (event_id = event.id) or
// marked global (is_global = true) — see migration
// backend/migrations/legacy/004_add_categories_and_cms.js. An API
// token inherits its owning admin's powers (no per-event scoping
// in apiTokenAuth), so accepting any category_id would silently
// mis-file uploads under a category belonging to a different event.
const category = await db('photo_categories')
.where({ id: parsedCategoryId })
.andWhere(function () {
this.where({ event_id: event.id }).orWhere('is_global', true);
})
.first();
if (!category) {
return res.status(400).json({
error: `Unknown or out-of-scope category_id ${parsedCategoryId}`,
});
}
categoryId = category.id;
if (category.slug === 'collage' || category.slug === 'collages') {
photoType = 'collage';
}
}
const ext = path.extname(req.file.originalname);
const finalName = `${Date.now()}_${crypto.randomBytes(4).toString('hex')}${ext}`;
// photo.path is stored relative to events/active so resolvePhotoStorageKey
@@ -408,7 +498,8 @@ router.post(
original_filename: req.file.originalname,
path: relPath,
thumbnail_path: thumbRel,
type: 'individual',
type: photoType,
category_id: categoryId,
size_bytes: stat.size,
width,
height,
@@ -432,7 +523,14 @@ router.post(
});
} catch (e) { /* non-fatal */ }
res.status(201).json({ id, filename: finalName, path: relPath, thumbnail_path: thumbRel, size_bytes: stat.size });
res.status(201).json({
id,
filename: finalName,
path: relPath,
thumbnail_path: thumbRel,
size_bytes: stat.size,
category_id: categoryId
});
} catch (error) {
logger.error('v1 POST /events/:id/photos failed', { error: error.message });
if (tempPath) await fs.unlink(tempPath).catch(() => {});
+52 -5
View File
@@ -9,6 +9,12 @@ const { queueEmail, getSupportEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
const feedbackService = require('./feedbackService');
const { getStorage } = require('./storage');
const { resolvePhotoStorageKey } = require('./photoResolver');
const { getUseOriginalFilenames } = require('./downloadFilenameService');
const {
sanitizeForZipEntry,
uniquifyZipNames,
} = require('../utils/filenameSanitizer');
async function archiveEvent(event) {
const storage = getStorage();
@@ -54,6 +60,40 @@ async function archiveEvent(event) {
// the zip directly from the storage backend.
const photoEntries = await storage.list(eventPrefix);
// #493: optionally rename zip entries to use original camera filenames.
// Build a Map<storage_key, original_filename> from the photos table so we
// can swap the basename of each entry while keeping the folder structure
// (e.g. `individual/DSC_1234.jpg` instead of `individual/slug_001.jpg`).
const useOriginal = await getUseOriginalFilenames();
const originalsByKey = new Map();
if (useOriginal) {
const photoRows = await db('photos').where('event_id', event.id).select('*');
for (const photoRow of photoRows) {
if (!photoRow.original_filename) continue;
try {
const key = resolvePhotoStorageKey(event, photoRow);
if (key) originalsByKey.set(key, photoRow.original_filename);
} catch {
// External-mode rows have no managed key; skip silently.
}
}
}
// Compute (subfolder, displayName) up front so collisions across the
// whole zip can be resolved deterministically with `_N` suffixes.
const photoNames = photoEntries.map((entry) => {
const rel = entry.key.startsWith(`${eventPrefix}/`)
? entry.key.slice(eventPrefix.length + 1)
: entry.key;
if (!useOriginal) return rel;
const originalBase = originalsByKey.get(entry.key);
if (!originalBase) return rel;
const sep = rel.lastIndexOf('/');
const folder = sep >= 0 ? rel.slice(0, sep + 1) : '';
return `${folder}${sanitizeForZipEntry(originalBase)}`;
});
const dedupedNames = uniquifyZipNames(photoNames);
let totalBytes = 0;
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(tmpArchive);
@@ -67,10 +107,9 @@ async function archiveEvent(event) {
archive.pipe(output);
const append = async () => {
for (const entry of photoEntries) {
const nameInZip = entry.key.startsWith(`${eventPrefix}/`)
? entry.key.slice(eventPrefix.length + 1)
: entry.key;
for (let i = 0; i < photoEntries.length; i += 1) {
const entry = photoEntries[i];
const nameInZip = dedupedNames[i];
const stream = await storage.get(entry.key);
archive.append(stream, { name: nameInZip });
}
@@ -129,7 +168,10 @@ async function archiveEvent(event) {
);
}
// Delete thumbnails for this event's photos.
// Delete derived images (thumbnails / heroes / previews / watermarks)
// for this event's photos. The originals are inside the zip; the
// derived tiers are throwaway and will be regenerated lazily on
// restore (or not at all for archived events that nobody opens).
const photos = await db('photos').where('event_id', event.id);
for (const photo of photos) {
if (photo.thumbnail_path) {
@@ -138,6 +180,11 @@ async function archiveEvent(event) {
if (photo.hero_path) {
await storage.delete(photo.hero_path).catch(() => {});
}
// Lightbox preview tier (#492). Same disposable-derived
// semantics as thumbnails / heroes — wipe on archive.
if (photo.preview_path) {
await storage.delete(photo.preview_path).catch(() => {});
}
// Best effort: remove watermarked variants too if a refactor added them.
if (photo.watermark_path) {
await storage.delete(photo.watermark_path).catch(() => {});
+10
View File
@@ -361,6 +361,16 @@ async function getFilesToBackupInternal(includeArchived = true) {
}
await scanDirectory(path.join(storagePath, 'thumbnails'), files, storagePath);
// Lightbox preview tier (#492). Cheap to back up — typically a few
// hundred KB per photo — and saves admins the regenerate cycle on
// a restore. Tolerated when missing (admins who never enabled the
// feature won't have the folder; scanDirectory short-circuits on
// ENOENT cleanly).
await scanDirectory(path.join(storagePath, 'previews'), files, storagePath);
// Heroes too — same logic; admins who picked a hero photo for the
// gallery header had its 1920x1080 file generated and was missed
// by the original backup walk before this addition.
await scanDirectory(path.join(storagePath, 'heroes'), files, storagePath);
await scanDirectory(path.join(storagePath, 'uploads'), files, storagePath);
return files;
@@ -0,0 +1,117 @@
/**
* Download filename resolution for the
* `general_use_original_filenames_for_downloads` setting (#493).
*
* Two responsibilities:
* - Cache the boolean setting so per-download reads don't hit the DB.
* - Map photos → display filenames (sanitized + dedup'd for zip entries).
*
* Storage paths are NOT touched: callers still locate files via
* `resolvePhotoStorageKey` / `resolvePhotoFilePath`. Only the user-visible
* download/zip-entry name changes when the setting is on.
*/
const { db } = require('../database/db');
const logger = require('../utils/logger');
const {
sanitizeForContentDisposition,
sanitizeForZipEntry,
uniquifyZipNames,
} = require('../utils/filenameSanitizer');
const SETTING_KEY = 'general_use_original_filenames_for_downloads';
const CACHE_TTL_MS = 60_000;
let cached = null; // boolean | null
let cachedAt = 0;
function clearCache() {
cached = null;
cachedAt = 0;
}
/**
* Read the toggle. Cached for CACHE_TTL_MS to keep per-download reads cheap.
* Falls back to `false` (current behaviour) on any error.
*/
async function getUseOriginalFilenames() {
const now = Date.now();
if (cached !== null && now - cachedAt < CACHE_TTL_MS) {
return cached;
}
try {
const row = await db('app_settings')
.where('setting_key', SETTING_KEY)
.first();
let value = false;
if (row && row.setting_value !== null && row.setting_value !== undefined) {
const raw = row.setting_value;
if (typeof raw === 'boolean') {
value = raw;
} else if (typeof raw === 'string') {
// setting_value is JSON-stringified on write (see adminSettings PUT /general).
try {
value = JSON.parse(raw) === true;
} catch {
value = raw === 'true';
}
} else {
value = Boolean(raw);
}
}
cached = value;
cachedAt = now;
return value;
} catch (err) {
logger.warn('downloadFilenameService.getUseOriginalFilenames error', { error: err.message });
return cached === null ? false : cached;
}
}
/**
* Pick the raw (unsanitised) filename to use for a single photo, given the
* toggle state. Falls back to `photo.filename` whenever the original is missing
* (legacy uploads before migration 062, or external-mode rows where it was
* never populated).
*/
function pickRawDownloadName(photo, useOriginal) {
if (useOriginal && photo && photo.original_filename) {
return photo.original_filename;
}
return (photo && photo.filename) || `photo-${photo && photo.id}.jpg`;
}
/**
* Header-safe filename for `Content-Disposition`. Pair with
* `buildContentDisposition()` from filenameSanitizer when the caller wants
* RFC 5987 unicode support; this helper returns only the ASCII fallback for
* routes that already construct the header by hand.
*/
function getDownloadFilenameForHeader(photo, useOriginal) {
return sanitizeForContentDisposition(pickRawDownloadName(photo, useOriginal));
}
/**
* Build a list of unique, zip-safe entry names for an ordered list of photos.
*
* @param {Array} photos photos in zip order
* @param {boolean} useOriginal toggle state
* @returns {string[]} same length as `photos`, with `_1` / `_2` suffixes on
* any duplicates (deterministic across runs because order is preserved)
*/
function getZipEntryNames(photos, useOriginal) {
const raw = photos.map((p) => sanitizeForZipEntry(pickRawDownloadName(p, useOriginal)));
return uniquifyZipNames(raw);
}
module.exports = {
SETTING_KEY,
clearCache,
getUseOriginalFilenames,
pickRawDownloadName,
getDownloadFilenameForHeader,
getZipEntryNames,
};
+11 -3
View File
@@ -23,6 +23,7 @@ const { db } = require('../database/db');
const watermarkService = require('./watermarkService');
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
const { getStorage } = require('./storage');
const { getUseOriginalFilenames, getZipEntryNames } = require('./downloadFilenameService');
const logger = require('../utils/logger');
const DEBOUNCE_MS = 5000;
@@ -134,6 +135,11 @@ class DownloadZipService {
tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-zipbuild-'));
const tmpPath = path.join(tmpDir, `${crypto.randomBytes(4).toString('hex')}-all.zip`);
// #493: resolve display filenames (with collision suffix) before the
// streaming starts so the loop just indexes the precomputed array.
const useOriginal = await getUseOriginalFilenames();
const entryNames = getZipEntryNames(photos, useOriginal);
// Build zip — level 0 (store only) since photos are already compressed
await new Promise((resolve, reject) => {
const output = fs.createWriteStream(tmpPath);
@@ -147,19 +153,21 @@ class DownloadZipService {
const hasMultipleTypes = uniqueTypes > 1;
const addPhotos = async () => {
for (const photo of photos) {
for (let i = 0; i < photos.length; i += 1) {
const photo = photos[i];
// Check if build was invalidated
if (this.versions.get(eventId) !== version) {
archive.abort();
return reject(new Error('Build invalidated'));
}
const entryName = entryNames[i];
let archiveName;
if (hasMultipleTypes) {
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
archiveName = path.join(folderName, photo.filename);
archiveName = path.join(folderName, entryName);
} else {
archiveName = photo.filename;
archiveName = entryName;
}
// External-mode photos still live on local disk; managed photos go
+7 -1
View File
@@ -116,7 +116,9 @@ async function getRecipientLanguage(email, eventId = null) {
.where('setting_key', 'general_default_language')
.first();
if (langSetting && langSetting.setting_value) {
return langSetting.setting_value;
let lang = langSetting.setting_value;
try { lang = JSON.parse(lang); } catch (_) {}
if (typeof lang === 'string' && lang.trim()) return lang.trim();
}
} catch (error) {
logger.error('Error fetching app settings language:', error);
@@ -140,6 +142,7 @@ async function getRecipientLanguage(email, eventId = null) {
{ domains: ['.nl', '.be'], language: 'nl' },
{ domains: ['.br', '.pt'], language: 'pt' },
{ domains: ['.ru', '.su'], language: 'ru' },
{ domains: ['.es'], language: 'es' },
];
for (const { domains, language: lang } of domainLanguageMap) {
if (domains.some(d => domain.endsWith(d))) {
@@ -497,6 +500,7 @@ async function processTemplate(template, variables, language = 'en') {
nl: '(Om veiligheidsredenen niet weergegeven)',
pt: '(Não exibido por motivos de segurança)',
ru: '(Не показано в целях безопасности)',
es: '(No se muestra por razones de seguridad)',
};
const noPasswordI18n = {
en: 'No password required',
@@ -504,6 +508,7 @@ async function processTemplate(template, variables, language = 'en') {
nl: 'Geen wachtwoord vereist',
pt: 'Nenhuma senha necessária',
ru: 'Пароль не требуется',
es: 'No se requiere contraseña',
};
// Sent by the publish-from-draft flow (adminEvents.js): by the time the
// event is published, only the bcrypt hash is stored, so the plaintext
@@ -515,6 +520,7 @@ async function processTemplate(template, variables, language = 'en') {
nl: 'Het wachtwoord dat u bij het aanmaken van de galerij hebt ingesteld',
pt: 'A senha definida ao criar a galeria',
ru: 'Пароль, заданный при создании галереи',
es: 'La contraseña que estableciste al crear la galería',
};
if (processedVariables.gallery_password === '{{password_security_message}}') {
+11 -1
View File
@@ -7,7 +7,12 @@ const SOCIAL_CRAWLER_PATTERNS = [
/facebookexternalhit/i,
/facebot/i,
/Twitterbot/i,
// WhatsApp's main app crawler is "WhatsApp/X.Y.Z"; the Business
// API and some Cloud API senders use "WhatsAppBot" or "wa-bot/" —
// detect both so API-driven sends get the rich preview too (#521).
/WhatsApp/i,
/WhatsAppBot/i,
/wa-bot/i,
/Slackbot/i,
/TelegramBot/i,
/SkypeUriPreview/i,
@@ -24,7 +29,12 @@ const SOCIAL_CRAWLER_PATTERNS = [
/Mastodon/i,
/Bluesky/i,
/OpenGraph/i,
/opengraph/i
/opengraph/i,
// Generic preview/scrape services commonly used in business
// messaging stacks (Twilio, LinkPreview.net, etc.). Match the
// canonical lowercase substring; the /i flag handles case.
/LinkPreview/i,
/Slack-ImgProxy/i
];
function isSocialCrawler(userAgent) {
+138
View File
@@ -30,6 +30,15 @@ const DEFAULT_HERO_WIDTH = 1920;
const DEFAULT_HERO_HEIGHT = 1080;
const DEFAULT_HERO_QUALITY = 85;
// Preview tier (#492). Aspect-preserved downscale for the lightbox so
// guests don't pay the full 512 MB original on every photo open.
// Same long edge as the hero (admins are already sizing for it) and
// quality 85 — JPEG artefacts at this size are imperceptible to clients
// browsing on phones / Retina laptops, and storage cost stays modest
// (~200500 KB per photo vs originals at multi-MB).
const DEFAULT_PREVIEW_LONG_EDGE = 1920;
const DEFAULT_PREVIEW_QUALITY = 85;
// Helper to parse setting value (handles both JSON-encoded and plain values)
function parseSettingValue(value) {
if (value === null || value === undefined) {
@@ -476,6 +485,132 @@ async function ensureHeroImage(photo) {
return null;
}
/**
* Generate a lightbox preview image (#492).
*
* Aspect-preserving downscale (`fit: 'inside'`) capped at
* DEFAULT_PREVIEW_LONG_EDGE. Distinct from generateHeroImage:
* - hero → 1920x1080 cover-cropped (gallery hero header banner)
* - preview → ≤1920px long edge, aspect preserved (lightbox tile)
*
* Output to `previews/preview_<filename>` so an admin who flips the
* setting back off can wipe the folder cleanly without touching
* thumbnails or heroes.
*/
async function generatePreviewImage(imagePath, options = {}) {
const filename = path.basename(imagePath);
const previewFilename = `preview_${filename}`;
const previewRelKey = path.posix.join('previews', previewFilename);
const storage = getStorage();
if (options.regenerate) {
await storage.delete(previewRelKey).catch(() => {});
}
try {
const metadata = await sharp(imagePath).metadata();
if (!metadata.width || !metadata.height) {
throw new Error('Invalid image metadata - file may be incomplete');
}
const longEdge = options.longEdge || DEFAULT_PREVIEW_LONG_EDGE;
const quality = options.quality || DEFAULT_PREVIEW_QUALITY;
let sharpInstance = sharp(imagePath, {
limitInputPixels: 268402689, // ~16k x 16k max
sequentialRead: true,
failOnError: false,
});
// Strip EXIF — same privacy reasoning as thumbnails/heroes.
sharpInstance = sharpInstance.withMetadata(false);
// fit: 'inside' + withoutEnlargement keeps small originals at
// their native size (no upscaling artefacts) and shrinks larger
// ones until both dimensions fit inside longEdge×longEdge.
sharpInstance = sharpInstance.resize(longEdge, longEdge, {
withoutEnlargement: true,
fit: 'inside',
});
sharpInstance = sharpInstance.jpeg({
quality,
progressive: true,
mozjpeg: true,
});
const buffer = await sharpInstance.toBuffer();
if (!buffer || buffer.length === 0) {
throw new Error('Generated preview image is empty');
}
await storage.put(previewRelKey, buffer, { contentType: 'image/jpeg' });
logger.info(`Generated preview image for ${filename}${previewRelKey}`);
return previewRelKey;
} catch (error) {
const msg = (error && error.message) ? error.message : String(error);
logger.error(`Failed to generate preview image for ${filename}: ${msg}`);
await storage.delete(previewRelKey).catch(() => {});
return null;
}
}
/**
* Validate an existing preview file is non-empty + readable by Sharp.
* Mirrors isHeroValid / isThumbnailValid.
*/
async function isPreviewValid(previewPath) {
const storage = getStorage();
try {
const stat = await storage.stat(previewPath);
if (!stat || stat.size === 0) return false;
if (storage.kind() === 'local') {
const localPath = storage.resolveLocalPath(previewPath);
await sharp(localPath).metadata();
}
return true;
} catch {
return false;
}
}
/**
* Lazy-generate the preview image for a photo if missing or invalid.
* Returns the storage key or null on failure (callers fall back to
* the original URL so the lightbox never shows a broken image).
*/
async function ensurePreviewImage(photo) {
const { resolvePhotoStorageKey } = require('./photoResolver');
let sourceKey;
try {
const event = await db('events').where('id', photo.event_id).first();
sourceKey = resolvePhotoStorageKey(event, photo);
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
logger.error(`Failed to resolve original key for preview (photo ${photo.id}): ${msg}`);
return null;
}
if (photo.preview_path) {
const ok = await isPreviewValid(photo.preview_path);
if (ok) return photo.preview_path;
logger.warn(`Invalid preview detected for photo ${photo.id}, regenerating…`);
}
const newPreviewPath = await withLocalCopy(sourceKey, (localPath) =>
generatePreviewImage(localPath, { regenerate: true })
);
if (newPreviewPath) {
await db('photos').where({ id: photo.id }).update({ preview_path: newPreviewPath });
return newPreviewPath;
}
return null;
}
/**
* Extract capture date from EXIF metadata
*/
@@ -525,6 +660,9 @@ module.exports = {
generateHeroImage,
isHeroValid,
ensureHeroImage,
generatePreviewImage,
isPreviewValid,
ensurePreviewImage,
extractCaptureDate,
withLocalCopy,
};
+93
View File
@@ -0,0 +1,93 @@
/**
* Tests for the shared slug util extracted in #525 from the inline
* pipelines in adminEvents.js, events.js, v1/events.js, adminArchives.js.
*
* Two contracts to pin:
* 1. ASCII inputs produce byte-identical output to the previous
* inline pipelines, so existing event/archive slugs in the DB
* keep resolving via the same lookup path after the refactor.
* 2. Accented characters (Portuguese, German, French, Spanish) are
* transliterated to their ASCII bases (Decoração → decoracao)
* instead of being dropped (Decoração → decorao) as the legacy
* pipelines did — same fix as #502 for category slugs.
*/
const { slugify } = require('../slug');
describe('slugify — ASCII parity with the legacy event-style pipeline', () => {
// Replays the exact transformation used by adminEvents.js before the
// refactor: lowercase → replace [^a-z0-9] with '-' → collapse → trim.
const legacy = (s) =>
String(s).toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const samples = [
'Wedding 2026',
' Hello World ',
'birthday-party-42',
'event_with_underscores',
'CamelCase Event Name',
'',
'event.with.dots',
'event!@#$%^&*()chars',
'2026-06-12',
];
it.each(samples)('matches legacy output for ASCII input: %j', (input) => {
expect(slugify(input)).toBe(legacy(input));
});
});
describe('slugify — accented characters (the #502 fix, now shared)', () => {
// The legacy pipeline produced f-mlia for "Família" because the í
// got replaced with '-' rather than being NFD-normalised to 'i'.
// These tests pin the corrected behaviour across the locales the
// app already ships in (de, es, fr, nl, pt, ru).
it.each([
['Decoração', 'decoracao'],
['Família', 'familia'],
['Recepção', 'recepcao'],
['Über uns', 'uber-uns'],
['Niño', 'nino'],
['Fête de famille', 'fete-de-famille'],
['L\'Évènement', 'l-evenement'],
['Crème Brûlée', 'creme-brulee'],
])('transliterates %j → %j', (input, expected) => {
expect(slugify(input)).toBe(expected);
});
it('CJK and other scripts without NFD decompositions still strip cleanly', () => {
// NFD doesn't decompose Chinese characters to ASCII, so they get
// dropped by the [^a-z0-9]+ replace. Output is sensible if not
// perfect — the surrounding ASCII tokens survive.
expect(slugify('Photo 混合 Test')).toBe('photo-test');
// Pure-CJK names collapse to empty after trim — caller's job to
// handle (typically by appending a uniqueness suffix).
expect(slugify('婚礼')).toBe('');
});
});
describe('slugify — input edge cases', () => {
it('returns empty string for null / undefined / empty', () => {
expect(slugify(null)).toBe('');
expect(slugify(undefined)).toBe('');
expect(slugify('')).toBe('');
});
it('coerces non-string input to string before slugifying', () => {
expect(slugify(2026)).toBe('2026');
expect(slugify(true)).toBe('true');
});
it('collapses any run of non-alphanumeric chars into a single dash', () => {
expect(slugify('a!@#$%b')).toBe('a-b');
expect(slugify('a b\t\nc')).toBe('a-b-c');
});
it('trims leading and trailing dashes', () => {
expect(slugify('---hello---')).toBe('hello');
expect(slugify('!!!world!!!')).toBe('world');
});
});
+121 -1
View File
@@ -1,3 +1,5 @@
const path = require('path');
/**
* Sanitize a string to be used as a filename component
* @param {string} str - The string to sanitize
@@ -51,7 +53,125 @@ function generatePhotoFilename(eventName, categoryName, counter, extension) {
return `${sanitizedEvent}_${sanitizedCategory}_${paddedCounter}${extension}`;
}
/**
* Strip characters that are unsafe inside a Content-Disposition `filename="..."`
* token: CR/LF/NUL (header injection), backslashes, double-quotes, and other
* control bytes. Returns an ASCII-only fallback name (non-ASCII bytes are
* dropped — pair with `buildContentDisposition()` which also emits a
* RFC 5987 `filename*=UTF-8''…` parameter so modern clients see unicode).
*
* Path separators are stripped so an `original_filename` like `../../etc/passwd`
* can never be coaxed into a directory write on a client that honours paths.
*/
function sanitizeForContentDisposition(name) {
if (!name) return 'download';
let sanitized = String(name)
// Header-breaking bytes
.replace(/[\r\n\0]/g, '')
// Other ASCII control characters (0x010x1F, 0x7F)
// eslint-disable-next-line no-control-regex
.replace(/[\x01-\x1F\x7F]/g, '')
// Path separators and quote chars that would close the quoted-string
.replace(/[/\\"]/g, '_')
.trim();
// Strip any non-ASCII for the legacy `filename=` token. The `filename*=`
// parameter carries the unicode form.
// eslint-disable-next-line no-control-regex
sanitized = sanitized.replace(/[^\x20-\x7E]/g, '_');
// Collapse runs of underscores introduced by replacement.
sanitized = sanitized.replace(/_{2,}/g, '_').replace(/^[_.]+|_+$/g, '');
return sanitized || 'download';
}
/**
* Build a full `Content-Disposition` header value with both an ASCII
* fallback (`filename="…"`) and an RFC 5987 unicode form
* (`filename*=UTF-8''…`). This is what RFC 6266 §4 recommends for any
* filename that may contain non-ASCII bytes (which `photos.original_filename`
* can, since it's the raw `multer.file.originalname`).
*/
function buildContentDisposition(name, disposition = 'attachment') {
const safeName = name ? String(name) : 'download';
const asciiFallback = sanitizeForContentDisposition(safeName);
// RFC 5987: percent-encode every byte that isn't an attr-char. encodeURIComponent
// is a superset of attr-char (it encodes `*'%` etc.) — close enough and
// browser-compatible.
const encoded = encodeURIComponent(safeName).replace(/['()]/g, escape);
return `${disposition}; filename="${asciiFallback}"; filename*=UTF-8''${encoded}`;
}
/**
* Sanitize a string for use as a zip-entry name. Preserves spaces,
* parentheses, and unicode (modern zip readers handle UTF-8 entry names),
* but strips path-traversal sequences and platform-reserved characters so
* extracting the zip can never escape its target directory.
*/
function sanitizeForZipEntry(name) {
if (!name) return 'download';
let sanitized = String(name)
// Header-breaking bytes (shouldn't appear in zip but cheap defence)
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1F\x7F]/g, '')
// Normalise path separators to underscore so `evil/../passwd` becomes
// `evil_.._passwd` instead of an actual subpath.
.replace(/[/\\]/g, '_')
// Strip leading dots so `..` can't become an upward reference.
.replace(/^\.+/, '')
.trim();
return sanitized || 'download';
}
/**
* Deterministically rename duplicate names by appending `_1`, `_2`, … before
* the extension. Input order is preserved; the first occurrence keeps its
* original name. Used when a bulk-download zip is built with original camera
* filenames and two photos in the same event happen to share one (e.g. same
* camera body across two shoot days).
*
* @param {string[]} names
* @returns {string[]} new array of the same length, with collisions resolved
*/
function uniquifyZipNames(names) {
const seen = new Map();
const out = new Array(names.length);
for (let i = 0; i < names.length; i += 1) {
const original = names[i] || 'download';
if (!seen.has(original)) {
seen.set(original, 0);
out[i] = original;
continue;
}
// Find the next free `_N` suffix. We bump the stored counter so the
// next collision picks the *next* number instead of starting from 1 again.
let n = seen.get(original) + 1;
const ext = path.extname(original);
const stem = ext ? original.slice(0, -ext.length) : original;
let candidate;
do {
candidate = `${stem}_${n}${ext}`;
n += 1;
} while (seen.has(candidate));
seen.set(original, n - 1);
seen.set(candidate, 0);
out[i] = candidate;
}
return out;
}
module.exports = {
sanitizeFilename,
generatePhotoFilename
generatePhotoFilename,
sanitizeForContentDisposition,
buildContentDisposition,
sanitizeForZipEntry,
uniquifyZipNames,
};
+35
View File
@@ -0,0 +1,35 @@
/**
* URL-safe slug generation shared across event, archive, and v1 upload
* routes (#525 follow-up to #502). Previously every caller had its own
* inline `name.toLowerCase().replace(/[^a-z0-9]/g, '-')` pipeline, each
* with the same latent bug: JS's `\w` and the ASCII alphanumeric class
* silently drop non-ASCII letters instead of transliterating them
* (`Decoração` → `decorao`, `Família` → `f-mlia`).
*
* Fix mirrors #502: NFD-normalize so accented characters split into a
* base letter + combining mark, then strip the combining-mark range
* (U+0300U+036F) so the ASCII base survives. Single regex pass after
* that — `[^a-z0-9]+` collapses any run of non-alphanumerics into one
* dash, no separate collapse step needed.
*
* For pure-ASCII input the output is byte-identical to the previous
* inline pipelines, so existing slugs continue to round-trip cleanly
* via lookups; only new inserts with non-ASCII names start producing
* the corrected slugs.
*
* Not exported as the default category slug — `adminCategories.js`
* intentionally preserves underscores (the legacy category pipeline
* used `\w` not `[a-z0-9]`), so changing it here would silently shift
* "wedding_party" → "wedding-party" on new inserts. Categories keep
* their own pipeline as fixed in #502.
*/
function slugify(input) {
return String(input ?? '')
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
module.exports = { slugify };
+37
View File
@@ -3,6 +3,43 @@
set -e
# Permission handling (#484): the image starts as root so this script can
# chown bind-mounted host volumes to UID 1001 (nodejs) before dropping
# privileges via su-exec. This avoids the fresh-install restart loop where
# the host directory's UID (commonly 1000) didn't match the container's
# hard-coded nodejs user. Compose deployments that pin `user:` to something
# other than root skip this branch — they own permissions themselves and hit
# the preflight check below instead.
if [ "$(id -u)" = "0" ]; then
if ! chown -R nodejs:nodejs /app/storage /app/data /app/logs 2>/dev/null; then
echo "ERROR: failed to chown /app/storage, /app/data, /app/logs to nodejs (UID 1001)." >&2
echo " This usually means the host filesystem rejects chown (e.g. NFS without root squash" >&2
echo " disabled, or a SELinux/AppArmor policy blocking the operation)." >&2
echo " Workaround: pre-chown the host directories to 1001:1001 and pin 'user: \"1001:1001\"'" >&2
echo " in your compose file so this script never tries to chown them itself." >&2
echo " See https://docs.picpeak.app/deployment/docker#permissions" >&2
exit 1
fi
exec su-exec nodejs:nodejs "$0" "$@"
fi
# Belt-and-suspenders: if we got here as non-root (compose `user:` override),
# verify the bind mounts are actually writable before proceeding. Failing
# loud here beats the previous behavior — silent mkdir-||-true at line 69
# followed by a confusing migration error and a restart loop.
_uid="$(id -u)"
_gid="$(id -g)"
for _dir in /app/storage /app/data /app/logs; do
if [ ! -w "$_dir" ]; then
echo "ERROR: $_dir is not writable by UID $_uid." >&2
echo " Either drop the 'user:' override from your compose file so the container starts as" >&2
echo " root and can self-fix permissions, or run on the host:" >&2
echo " chown -R $_uid:$_gid <host-mount-for-$_dir>" >&2
echo " See https://docs.picpeak.app/deployment/docker#permissions" >&2
exit 1
fi
done
host="${DB_HOST:-postgres}"
port="${DB_PORT:-5432}"
user="${DB_USER:-picpeak}"
+24 -3
View File
@@ -15,7 +15,13 @@ services:
- picpeak-network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-picpeak}"]
# `pg_isready -U <user>` without -d defaults to probing a database
# whose name matches the user — postgres then logs constant
# `FATAL: database "picpeak" does not exist` even though the
# actual DB is `picpeak_prod`. Pinning -d to DB_NAME makes the
# probe hit the real database and silences the log noise that
# made #484's reporter think the install was broken.
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-picpeak} -d ${DB_NAME:-picpeak}"]
interval: 10s
timeout: 5s
retries: 5
@@ -64,8 +70,13 @@ services:
condition: service_healthy
restart: unless-stopped
healthcheck:
# Backend exposes /health on internal port 3000
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
# Backend exposes /health on internal port 3000.
# The backend image only ships wget (Alpine base) — using curl
# here makes `docker ps` show the container as `unhealthy`
# indefinitely even when /health responds. Mirrors the wget-based
# HEALTHCHECK already declared in backend/Dockerfile so docker
# compose, plain `docker run`, and `docker ps` all agree.
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
@@ -77,6 +88,16 @@ services:
container_name: picpeak-frontend
# Note: Pre-built frontend uses Nginx to proxy /api to backend:3001.
# Prefer keeping API base as '/api' in builds to avoid CORS.
environment:
# Substituted into index.html at container start (see frontend/
# docker-entrypoint.sh) so social link previews reaching the
# static SPA shell (WhatsApp Business API, Twilio, LinkPreview,
# etc. — see #521) show the configured brand instead of the
# generic "PicPeak" default. Defaults applied when unset; restart
# the frontend container after changing for the new title to
# take effect.
- BRAND_TITLE=${BRAND_TITLE:-PicPeak}
- BRAND_DESCRIPTION=${BRAND_DESCRIPTION:-Photo gallery shared with PicPeak.}
ports:
- "${FRONTEND_PORT:-3000}:80"
networks:
+9 -5
View File
@@ -31,11 +31,11 @@ services:
- ADMIN_URL=${ADMIN_URL:-http://localhost:3001}
- TZ=${TZ:-UTC}
- STORAGE_PATH=/app/storage
# Optional: run container as matching host user to avoid bind mount permission issues
- PUID=${PUID:-1001}
- PGID=${PGID:-1001}
# Use host-matching user ID/GID so bind-mounted folders are writable
user: "${PUID:-1001}:${PGID:-1001}"
# No `user:` directive — as of #484, the container starts as root,
# chowns the bind mounts to nodejs (UID 1001), then drops privileges
# via su-exec. PUID/PGID env vars are no longer read; if you need
# a different runtime UID, pre-chown the host dirs and pin
# `user: "<uid>:<gid>"` here.
volumes:
- ./events:/app/events
- ./data:/app/data
@@ -121,6 +121,10 @@ services:
restart: unless-stopped
environment:
- NODE_ENV=${NODE_ENV:-production}
# Static social-preview brand (#521) — substituted into
# index.html at container start; see frontend/docker-entrypoint.sh.
- BRAND_TITLE=${BRAND_TITLE:-PicPeak}
- BRAND_DESCRIPTION=${BRAND_DESCRIPTION:-Photo gallery shared with PicPeak.}
ports:
- "${FRONTEND_PORT:-3000}:80"
depends_on:
+19 -3
View File
@@ -33,8 +33,11 @@ FROM nginx:1.28-alpine
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN apk upgrade --no-cache
# Install runtime dependencies
RUN apk add --no-cache curl
# Install runtime dependencies. `gettext` provides envsubst, used by
# docker-entrypoint.sh for the BRAND_TITLE / BRAND_DESCRIPTION runtime
# substitution into index.html (#521 — runtime fix for self-hosters
# on the pre-built GHCR image who can't override at build time).
RUN apk add --no-cache curl gettext
# Remove default nginx config
RUN rm -rf /etc/nginx/conf.d/*
@@ -45,6 +48,16 @@ COPY nginx.conf /etc/nginx/conf.d/default.conf
# Copy built application from builder stage
COPY --from=builder /app/dist /usr/share/nginx/html
# Snapshot index.html as a template so the entrypoint always renders
# from a known-good source — not from its own previous substitution.
# Container restarts can change BRAND_TITLE freely; the rendered file
# is recomputed from the .tpl each time.
RUN mv /usr/share/nginx/html/index.html /usr/share/nginx/html/index.html.tpl
# Runtime entrypoint that envsubsts the template and execs nginx
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Set permissions (nginx user already exists in nginx:alpine)
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
@@ -62,5 +75,8 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
# Switch to non-root user
USER nginx
# Start nginx
# Start nginx via the entrypoint so each container start re-renders
# index.html from the template against the current BRAND_TITLE /
# BRAND_DESCRIPTION env vars (defaults applied when unset).
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["nginx", "-g", "daemon off;"]
+40
View File
@@ -0,0 +1,40 @@
#!/bin/sh
# Frontend container entrypoint (#521).
#
# Renders /usr/share/nginx/html/index.html from a build-time .tpl
# snapshot, substituting BRAND_TITLE / BRAND_DESCRIPTION env vars into
# the static HTML head. This is what self-hosters running the pre-built
# GHCR image use to brand their link-preview fallback — see the matching
# comment in frontend/index.html for the three-path architecture
# (per-event OG endpoint, crawler-detected SPA shell, and this static
# fallback that catches WhatsApp Business / Twilio / LinkPreview).
#
# Re-runs on every container start. The .tpl is the immutable source so
# changing BRAND_TITLE in compose env and `docker compose up -d frontend`
# is enough — no rebuild required.
#
# Locked to BRAND_TITLE + BRAND_DESCRIPTION explicitly (rather than
# letting envsubst expand every ${...} it finds) so the JS bundle's
# template literals in /assets/*.js stay untouched if anyone ever
# accidentally points the substitution at them.
set -eu
: "${BRAND_TITLE:=PicPeak}"
: "${BRAND_DESCRIPTION:=Photo gallery shared with PicPeak.}"
export BRAND_TITLE BRAND_DESCRIPTION
TEMPLATE=/usr/share/nginx/html/index.html.tpl
RENDERED=/usr/share/nginx/html/index.html
if [ -f "$TEMPLATE" ]; then
envsubst '${BRAND_TITLE} ${BRAND_DESCRIPTION}' < "$TEMPLATE" > "$RENDERED"
else
# Template missing — image build skipped the .tpl rename for some
# reason. Don't crash: nginx can still serve whatever is at
# $RENDERED (probably the unsubstituted output of `npm run build`).
# Log loudly so it's visible during boot.
echo "[frontend-entrypoint] WARN: $TEMPLATE missing; serving $RENDERED as-is." >&2
fi
exec "$@"
+33 -1
View File
@@ -4,7 +4,39 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>PicPeak - Photo Sharing Platform</title>
<!--
Static fallback title + Open Graph defaults (#521).
The runtime SPA updates these once React loads, and social-crawler
User-Agents hitting /gallery/<slug> get a per-event rich preview
served by backend's galleryOgService instead of this static shell.
But the third path — link previews fetched by WhatsApp Business
API, Twilio, LinkPreview, or any service that caches metadata
with a non-crawler UA — gets *this* HTML as-is. Defaulting the
title to "PicPeak - Photo Sharing Platform" left every such
preview looking unbranded for self-hosted installs.
${BRAND_TITLE} / ${BRAND_DESCRIPTION} are replaced at *container
start* by the frontend image's docker-entrypoint.sh (envsubst on
an index.html.tpl snapshot taken at image build). That keeps the
tokens working even for self-hosters running the pre-built GHCR
image — set BRAND_TITLE in compose env and the next container
restart picks it up. No frontend rebuild needed.
Vite dev server doesn't run the entrypoint, so dev mode shows the
literal tokens in the tab title — acceptable since dev sessions
don't care about social previews.
-->
<title>${BRAND_TITLE}</title>
<meta name="description" content="${BRAND_DESCRIPTION}" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content="${BRAND_TITLE}" />
<meta property="og:title" content="${BRAND_TITLE}" />
<meta property="og:description" content="${BRAND_DESCRIPTION}" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${BRAND_TITLE}" />
<meta name="twitter:description" content="${BRAND_DESCRIPTION}" />
<!-- Pre-React theme bootstrap (#358).
The browser may paint the very first frame before our inline
+24 -9
View File
@@ -1,3 +1,14 @@
# Honour the outer reverse proxy's X-Forwarded-Proto when present (e.g. NPM,
# Traefik, Caddy in front of PicPeak). Falls back to nginx's own $scheme when
# the header is absent (direct access / no outer proxy). Without this the
# inner nginx was always forwarding "http" to the backend because the outer
# proxy → inner nginx hop is plain HTTP, breaking Secure cookies and HTTPS
# URL generation in the backend. See issue #547.
map $http_x_forwarded_proto $real_proto {
default $http_x_forwarded_proto;
"" $scheme;
}
server {
listen 80;
server_name localhost;
@@ -80,7 +91,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $real_proto;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 86400;
@@ -97,7 +108,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $real_proto;
# Cache photos
proxy_cache_valid 200 302 1d;
@@ -112,7 +123,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $real_proto;
# Cache thumbnails
proxy_cache_valid 200 302 7d;
@@ -128,7 +139,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $real_proto;
# Cache uploads
proxy_cache_valid 200 302 7d;
@@ -146,7 +157,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $real_proto;
# Fonts rarely change; cache aggressively (matches backend Cache-Control).
proxy_cache_valid 200 302 7d;
@@ -161,7 +172,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $real_proto;
}
# Delegate root requests to backend for public landing page handling
@@ -175,7 +186,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $real_proto;
proxy_read_timeout 60s;
}
@@ -184,7 +195,11 @@ server {
# meta tags never reach them. Route those UAs to backend's /og handler
# via internal rewrite; humans fall through to the SPA via try_files.
location ~ ^/gallery/(?<gallery_slug>[A-Za-z0-9_-]+)(?:/[^/]+)?/?$ {
if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|Slackbot|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph)") {
# Keep this list in sync with SOCIAL_CRAWLER_PATTERNS in
# backend/src/services/galleryOgService.js. WhatsAppBot / wa-bot
# and LinkPreview / Slack-ImgProxy added in #521 to catch
# business-API preview fetchers that aren't the main WhatsApp app.
if ($http_user_agent ~* "(facebookexternalhit|facebot|Twitterbot|WhatsApp|WhatsAppBot|wa-bot|Slackbot|Slack-ImgProxy|TelegramBot|SkypeUriPreview|Discordbot|LinkedInBot|Pinterest|vkShare|redditbot|Embedly|iframely|Snapchat|Applebot|Mastodon|Bluesky|OpenGraph|LinkPreview)") {
rewrite ^ /og/gallery/$gallery_slug last;
}
try_files $uri $uri/ /index.html;
@@ -199,7 +214,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $real_proto;
}
# SPA fallback
+9 -2
View File
@@ -1,3 +1,10 @@
# Honour outer reverse-proxy's X-Forwarded-Proto when present (see #547 /
# frontend/nginx.conf for full rationale).
map $http_x_forwarded_proto $real_proto {
default $http_x_forwarded_proto;
"" $scheme;
}
server {
listen 80;
server_name localhost;
@@ -10,7 +17,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $real_proto;
}
# Photos proxy to backend
@@ -41,7 +48,7 @@ server {
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $real_proto;
}
# SPA fallback
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.49.1-beta.0",
"version": "3.44.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -9,7 +9,7 @@
"build:check": "tsc -b && node ./scripts/build.js",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run src/components/admin/__tests__/ThemeCustomizerEnhanced.test.tsx",
"test": "vitest run",
"i18n:extract": "i18next-cli extract",
"i18n:status": "i18next-cli status",
"i18n:ci": "i18next-cli extract --ci --dry-run"
+63 -15
View File
@@ -79,14 +79,16 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
);
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
const [isDragOver, setIsDragOver] = useState(false);
// Shared filter + per-upload-limit pipeline used by both the file-input
// change handler and the drop handler. #504 — without the drop handler
// the dashed-border zone looked draggable but silently fell through to
// the browser's default "open the file in a new tab" behaviour.
const addFiles = (incoming: File[]) => {
const imageFiles = incoming.filter((file) => allowedMimeTypes.includes(file.type));
if (imageFiles.length === 0) return;
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const imageFiles = files.filter(file =>
allowedMimeTypes.includes(file.type)
);
// Check total file count with existing files
const totalFiles = selectedFiles.length + imageFiles.length;
if (totalFiles > maxFilesPerUpload) {
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
@@ -101,11 +103,44 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
);
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
setSelectedFiles((prev) => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
return;
}
setSelectedFiles(prev => [...prev, ...imageFiles]);
setSelectedFiles((prev) => [...prev, ...imageFiles]);
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
addFiles(Array.from(e.target.files || []));
// Reset the input so picking the same files again still fires onChange.
if (e.target.value) e.target.value = '';
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// dropEffect must be set on every dragover for the cursor to render
// the "copy" affordance in Chrome/Firefox.
e.dataTransfer.dropEffect = 'copy';
if (!isDragOver) setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// dragleave fires for every child node the cursor passes — only flip
// the highlight off when the cursor leaves the zone itself, otherwise
// it strobes on/off as the user moves over the icon and text.
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = Array.from(e.dataTransfer.files || []);
addFiles(files);
};
const removeFile = (index: number) => {
@@ -128,9 +163,14 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
setUploadProgress(0);
setUploadIds([]);
// For large uploads, chunk the files by both count AND size to prevent memory/network issues
// For large uploads, chunk the files by both count AND size to prevent memory/network issues.
// #509: the per-chunk byte cap MUST be tunable so users behind Cloudflare Tunnel and other
// reverse proxies with request-size limits can drop it below their proxy's cap. Falls back
// to 95MB (Cloudflare-safe headroom under 100MB) when the setting is unset — that matches
// the value the migration seeds and is what worked in #208's resolution.
const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk
const MAX_BYTES_PER_CHUNK = 500 * 1024 * 1024; // Max 500MB per chunk (nginx limit is 1GB)
const maxBatchSizeMb = Number(settings?.general_max_upload_batch_size_mb) || 95;
const MAX_BYTES_PER_CHUNK = maxBatchSizeMb * 1024 * 1024;
const chunks: File[][] = [];
let currentChunk: File[] = [];
@@ -349,14 +389,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
</label>
</div>
{/* File Input Area */}
{/* File Input Area — accepts both click-to-pick and drag-and-drop (#504). */}
<div
className={clsx(
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
"border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer",
"hover:border-accent-dark hover:bg-accent-dark/15",
selectedFiles.length > 0 ? "border-accent-dark bg-accent-dark/15" : "border-neutral-300 dark:border-neutral-600"
isDragOver
? "border-accent-dark bg-accent-dark/25"
: selectedFiles.length > 0
? "border-accent-dark bg-accent-dark/15"
: "border-neutral-300 dark:border-neutral-600"
)}
onClick={() => fileInputRef.current?.click()}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className="w-12 h-12 mx-auto text-neutral-400 dark:text-neutral-500 mb-4" />
<p className="text-neutral-700 dark:text-neutral-300 font-medium mb-1">
@@ -1,6 +1,8 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import type { ReactElement } from 'react';
import { ThemeCustomizerEnhanced } from '../ThemeCustomizerEnhanced';
import type { ThemeConfig } from '../../../types/theme.types';
@@ -15,6 +17,16 @@ vi.mock('react-i18next', async () => {
};
});
// Component uses useQuery for admin-settings + fonts; tests don't exercise
// those data paths, so just give them a client that won't retry on the
// (intentionally absent) network.
const renderWithQueryClient = (ui: ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } }
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
describe('ThemeCustomizerEnhanced', () => {
const baseTheme: ThemeConfig = {
primaryColor: '#000000',
@@ -32,7 +44,7 @@ describe('ThemeCustomizerEnhanced', () => {
const handleChange = vi.fn();
const handleApply = vi.fn().mockResolvedValue(undefined);
render(
renderWithQueryClient(
<ThemeCustomizerEnhanced
value={baseTheme}
onChange={handleChange}
@@ -55,7 +67,7 @@ describe('ThemeCustomizerEnhanced', () => {
it('disables the Apply button while applying', () => {
const handleChange = vi.fn();
render(
renderWithQueryClient(
<ThemeCustomizerEnhanced
value={baseTheme}
onChange={handleChange}
@@ -54,6 +54,14 @@ const FRFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) =>
</svg>
);
const ESFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
<path fill="#AA151B" d="M0 0h640v120H0z"/>
<path fill="#F1BF00" d="M0 120h640v240H0z"/>
<path fill="#AA151B" d="M0 360h640v120H0z"/>
</svg>
);
export const SUPPORTED_LANGUAGES = [
{ code: 'en', name: 'English', Flag: GBFlag },
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
@@ -61,6 +69,7 @@ export const SUPPORTED_LANGUAGES = [
{ code: 'pt', name: 'Português', Flag: PTBRFlag },
{ code: 'nl', name: 'Nederlands', Flag: NLFlag },
{ code: 'fr', name: 'Français', Flag: FRFlag },
{ code: 'es', name: 'Español', Flag: ESFlag },
];
export const LanguageSelector: React.FC = () => {
@@ -78,11 +87,17 @@ export const LanguageSelector: React.FC = () => {
<div className="relative">
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
className="flex items-center gap-2 px-2 sm:px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
// On <sm the language *name* is hidden — the Globe + flag pair
// is enough recognition on its own and stops this control from
// pushing into the company-name title on narrow mobile widths
// (#523). Full name stays on sm+ where there's room.
aria-label={currentLanguage.name}
title={currentLanguage.name}
>
<Globe className="w-4 h-4" />
<currentLanguage.Flag className="w-5 h-5" />
<span>{currentLanguage.name}</span>
<span className="hidden sm:inline">{currentLanguage.name}</span>
</button>
{isOpen && (
@@ -47,6 +47,9 @@ interface GalleryLayoutProps {
youtube_url?: string;
promo_markdown?: string;
promo_position?: 'above_footer' | 'below_footer';
// Horizontal alignment for the promo content (#482). Defaults
// to 'center' so the banner aligns with the footer.
promo_alignment?: 'left' | 'center' | 'right';
};
showLogout?: boolean;
onLogout?: () => void;
@@ -226,12 +229,36 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
})().trim();
const promoPosition: 'above_footer' | 'below_footer' = brandingSettings?.promo_position === 'below_footer' ? 'below_footer' : 'above_footer';
// Promo alignment (#482). Defaults to 'center' to match the gallery
// footer's `text-center px-4` so the banner reads as part of the
// same composition as the footer beneath it. Admin can flip to
// 'left' or 'right' from Settings → Branding.
const promoAlignment: 'left' | 'center' | 'right' =
brandingSettings?.promo_alignment === 'left' ? 'left'
: brandingSettings?.promo_alignment === 'right' ? 'right'
: 'center';
const promoTextAlignClass =
promoAlignment === 'left' ? 'text-left'
: promoAlignment === 'right' ? 'text-right'
: 'text-center';
const promoSlot = promoMarkdown ? (
<div className="gallery-promo border-t border-surface bg-surface/50">
<div className="container py-4 sm:py-6">
<div className="max-w-3xl mx-auto text-sm text-theme">
<MarkdownContent source={promoMarkdown} className="prose-sm prose-a:text-accent" />
</div>
{/*
* Inner block uses .container (matches the footer's container
* width) + the alignment class. We deliberately drop the
* previous max-w-3xl wrapper — it created a narrower column
* that read as visually offset from the full-width footer
* (#482, reported by Rekoo-PS). The `prose` class is needed
* for the prose-a:text-accent modifier to actually take effect
* (modifiers without an outer .prose are no-ops in Tailwind
* Typography).
*/}
<div className={`container py-4 sm:py-6 px-4 ${promoTextAlignClass}`}>
<MarkdownContent
source={promoMarkdown}
className={`prose prose-sm max-w-none mx-auto text-sm text-theme prose-a:text-accent ${promoTextAlignClass}`}
/>
</div>
</div>
) : null;
+90 -18
View File
@@ -23,6 +23,7 @@ import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { api } from '../../config/api';
import { Upload, Menu, Eye, EyeOff, Shield } from 'lucide-react';
import { galleryService } from '../../services/gallery.service';
import { feedbackService } from '../../services/feedback.service';
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
import { usePublicSettings } from '../../hooks/usePublicSettings';
@@ -143,6 +144,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const disableRightClick = data?.event?.disable_right_click === true;
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
const useCanvasRendering = data?.event?.use_canvas_rendering === true;
// #508 — surface original camera filenames in the lightbox when the
// admin has flipped the same toggle that drives original-name downloads.
const showOriginalFilename = data?.event?.use_original_filenames === true;
// DevTools protection - enabled by individual setting OR legacy protection level
const devToolsEnabled = enableDevtoolsProtection || protectionLevel === 'enhanced' || protectionLevel === 'maximum';
@@ -226,6 +230,46 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
}, [feedbackSettings]);
// In guest identity mode both the "Liked / Favorited / Rated /
// Commented" filters AND the matching chip-count labels need to scope
// to the *current guest's* interactions, not the global aggregates on
// each photo row (#538 bug 1). Pull the current guest's feedback via
// /my-feedback (already keyed by x-guest-token in the api interceptor)
// and build per-type photo-id sets so both consumers below can do
// O(1) lookups.
//
// Always-on in guest mode (not gated on the active filter) because
// the chip counts render whether or not a feedback filter is selected
// — gating on filterType would leave "Liked (0)" stale until the user
// clicks the chip, which is the same UX cliff bug 1 was reporting.
const isGuestIdentityMode = feedbackSettings?.identity_mode === 'guest';
const { data: myFeedbackRows } = useQuery<Array<{
photo_id: number;
feedback_type: 'like' | 'favorite' | 'rating' | 'comment';
}>>({
queryKey: ['my-feedback', slug],
queryFn: () => feedbackService.getMyFeedback(slug),
enabled: isGuestIdentityMode && !!slug,
staleTime: 30 * 1000,
});
const myFeedbackPhotoIds = useMemo(() => {
const sets = {
liked: new Set<number>(),
favorited: new Set<number>(),
rated: new Set<number>(),
commented: new Set<number>(),
};
if (!myFeedbackRows) return sets;
for (const row of myFeedbackRows) {
if (row.feedback_type === 'like') sets.liked.add(row.photo_id);
else if (row.feedback_type === 'favorite') sets.favorited.add(row.photo_id);
else if (row.feedback_type === 'rating') sets.rated.add(row.photo_id);
else if (row.feedback_type === 'comment') sets.commented.add(row.photo_id);
}
return sets;
}, [myFeedbackRows]);
// Apply branding settings
useEffect(() => {
if (settingsData) {
@@ -253,6 +297,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
youtube_url: settingsData.branding_youtube_url || '',
promo_markdown: settingsData.branding_promo_markdown || '',
promo_position: settingsData.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer',
// Promo alignment (#482). Defaults to 'center' to match the
// gallery footer; see GalleryLayout.
promo_alignment: ['left', 'center', 'right'].includes(settingsData.branding_promo_alignment)
? settingsData.branding_promo_alignment
: 'center',
});
}
}, [settingsData]);
@@ -436,19 +485,33 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
);
}
// Apply feedback filter
// Apply feedback filter. In guest identity mode the filter has to
// scope to the *current guest's* interactions (#538 bug 1) — the
// aggregate counts on each photo row are global across all guests,
// which gave an empty grid when the guest had liked photos that
// nobody else had touched. Falls back to the aggregate-count check
// in simple/non-guest mode where there's no per-person identity to
// scope by.
switch (filterType) {
case 'liked':
photos = photos.filter(photo => (photo.like_count || 0) > 0);
photos = isGuestIdentityMode
? photos.filter(photo => myFeedbackPhotoIds.liked.has(photo.id))
: photos.filter(photo => (photo.like_count || 0) > 0);
break;
case 'favorited':
photos = photos.filter(photo => (photo.favorite_count || 0) > 0);
photos = isGuestIdentityMode
? photos.filter(photo => myFeedbackPhotoIds.favorited.has(photo.id))
: photos.filter(photo => (photo.favorite_count || 0) > 0);
break;
case 'rated':
photos = photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0);
photos = isGuestIdentityMode
? photos.filter(photo => myFeedbackPhotoIds.rated.has(photo.id))
: photos.filter(photo => (photo.average_rating || 0) > 0 || (photo.total_ratings || 0) > 0);
break;
case 'commented':
photos = photos.filter(photo => (photo.comment_count || 0) > 0);
photos = isGuestIdentityMode
? photos.filter(photo => myFeedbackPhotoIds.commented.has(photo.id))
: photos.filter(photo => (photo.comment_count || 0) > 0);
break;
default:
break;
@@ -494,22 +557,29 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
return photos;
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, filterType, mediaFilter]);
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, filterType, mediaFilter, isGuestIdentityMode, myFeedbackPhotoIds]);
const likeCount = useMemo(
() => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0,
[data?.photos]
);
// Counts shown in the filter chips ("Liked (N)", etc.). In guest
// mode these need to mirror the per-guest filter behaviour above —
// otherwise the chip says "Liked (5)" globally but clicking it
// surfaces 3 (the guest's own subset), which is the same confusing
// mismatch #538 reported for the filter itself. Fall back to the
// global aggregate in simple mode where no per-person identity
// exists.
const likeCount = useMemo(() => {
if (isGuestIdentityMode) return myFeedbackPhotoIds.liked.size;
return data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0;
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
const favoriteCount = useMemo(
() => data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0,
[data?.photos]
);
const favoriteCount = useMemo(() => {
if (isGuestIdentityMode) return myFeedbackPhotoIds.favorited.size;
return data?.photos?.filter(p => (p.favorite_count ?? 0) > 0).length || 0;
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
const ratedCount = useMemo(
() => data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0,
[data?.photos]
);
const ratedCount = useMemo(() => {
if (isGuestIdentityMode) return myFeedbackPhotoIds.rated.size;
return data?.photos?.filter(p => (p.total_ratings || 0) > 0 || (p.average_rating || 0) > 0).length || 0;
}, [data?.photos, isGuestIdentityMode, myFeedbackPhotoIds]);
// Check if downloads are allowed (both event setting and not expired)
const allowDownloads = !isExpired && (data?.event?.allow_downloads === true);
@@ -672,6 +742,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
welcomeMessage={event.welcome_message}
onLogout={logout}
showOriginalFilename={showOriginalFilename}
/>
{/* Upload Modal for full-page layouts */}
@@ -913,6 +984,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
welcomeMessage={event.welcome_message}
isClient={isClient}
onToggleVisibility={isClient ? handleToggleVisibility : undefined}
showOriginalFilename={showOriginalFilename}
/>
</div>
@@ -71,6 +71,9 @@ interface PhotoGridWithLayoutsProps {
// Client visibility controls (#172)
isClient?: boolean;
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
// Mirror of the admin original-filename toggle (#508). When true, the
// lightbox bottom toolbar surfaces each photo's original camera name.
showOriginalFilename?: boolean;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
@@ -105,7 +108,8 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
welcomeMessage,
onLogout,
isClient = false,
onToggleVisibility
onToggleVisibility,
showOriginalFilename = false,
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
@@ -238,6 +242,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
onLogout,
isClient,
onToggleVisibility,
showOriginalFilename,
};
// Determine if we should show hero header (decoupled from layout)
@@ -379,6 +384,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
enableDevtoolsProtection={enableDevtoolsProtection}
initialShowFeedback={openFeedbackInitially}
onFeedbackChange={onFeedbackChange}
showOriginalFilename={showOriginalFilename}
/>
)}
</>
+114 -16
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { useSavePhotoToDevice } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common';
import { PhotoFeedback } from './PhotoFeedback';
import { feedbackService } from '../../services/feedback.service';
@@ -24,6 +24,11 @@ interface PhotoLightboxProps {
onFeedbackChange?: () => void;
disableRightClick?: boolean;
enableDevtoolsProtection?: boolean;
// When true, surface each photo's original camera filename in the
// bottom toolbar — useful for photographers matching guest selections
// back to source files (#508). Tied to the admin-side toggle that
// also drives original-filename downloads (#493).
showOriginalFilename?: boolean;
}
export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
@@ -40,6 +45,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
onFeedbackChange,
disableRightClick = false,
enableDevtoolsProtection = false,
showOriginalFilename = false,
}) => {
const [currentIndex, setCurrentIndex] = useState(initialIndex);
const [zoom, setZoom] = useState(1);
@@ -71,6 +77,8 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
feedback_enabled?: boolean;
allow_likes?: boolean;
allow_ratings?: boolean;
allow_comments?: boolean;
show_feedback_to_guests?: boolean;
require_name_email?: boolean;
} | null>(null);
const [myLiked, setMyLiked] = useState<boolean>(false);
@@ -91,7 +99,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}, []);
const downloadPhotoMutation = useDownloadPhoto();
// Save-aware download. On mobile (where Web Share + files is supported)
// this opens the OS share sheet so "Save to Photos" actually lands in
// the Photos/Gallery app — matters for non-technical clients who
// otherwise have to chain Files → unzip → save (#531). Desktop and
// unsupported browsers fall through to a regular <a download>.
const downloadPhotoMutation = useSavePhotoToDevice();
const currentPhoto = photos[currentIndex];
// DevTools protection - enabled by individual setting OR legacy protection level
@@ -398,6 +411,16 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
setDragX(0);
}
}
} else if (e.touches.length === 1 && zoom > 1) {
// Single-finger pan when zoomed in (#532). Mirrors the desktop
// handleMouseDown path so mobile users can drag a zoomed image
// around instead of being stuck looking at the centre crop.
// Carousel swipe is disabled in this branch — when zoom > 1 the
// gesture has to mean "pan", not "next photo", or zoomed nav
// becomes unusable.
const t = e.touches[0];
setIsDragging(true);
setDragStart({ x: t.clientX - dragOffset.x, y: t.clientY - dragOffset.y });
} else if (e.touches.length === 1 && zoom <= 1 && (phase === 'idle' || phase === 'dragging')) {
const t = e.touches[0];
swipeStartRef.current = { x: t.clientX, y: t.clientY, t: Date.now() };
@@ -419,6 +442,24 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const newZoom = Math.max(1, Math.min(3, zoom * scale));
setZoom(newZoom);
setTouchDistance(newDistance);
// Pinch-out back down to 1.0 has to re-centre the image — without
// this the previous pan offset persists and the photo sits off-
// centre at the natural zoom level (#532 follow-on).
if (newZoom <= 1 && (dragOffset.x !== 0 || dragOffset.y !== 0)) {
setDragOffset({ x: 0, y: 0 });
}
return;
}
if (isDragging && zoom > 1 && e.touches.length === 1) {
// Single-finger pan when zoomed (#532). Touch counterpart to
// handleMouseMove. Same dragOffset state so the transform on the
// <img> stays consistent across input modalities.
const t = e.touches[0];
setDragOffset({
x: t.clientX - dragStart.x,
y: t.clientY - dragStart.y,
});
return;
}
@@ -446,6 +487,10 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const handleTouchEnd = (e: React.TouchEvent) => {
setTouchDistance(null);
// Release single-finger pan state (#532). The pan offset itself
// persists so the image stays where the user left it — only the
// "actively dragging" flag clears.
if (isDragging) setIsDragging(false);
const start = swipeStartRef.current;
if (phase === 'dragging' && start && e.changedTouches.length > 0) {
const t = e.changedTouches[0];
@@ -593,10 +638,23 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}}
>
<div className="max-w-4xl mx-auto flex items-center justify-between gap-2 flex-wrap">
<div className="text-white">
<div className="text-white min-w-0">
<p className="text-sm opacity-75">
{currentIndex + 1} / {photos.length}
</p>
{/* #508 — original camera filename next to the counter when
the admin has flipped the matching toggle. Falls back to
the storage filename only if `original_filename` is null
(pre-migration-062 uploads). truncate + max-w keep long
names from pushing the action row to another line. */}
{showOriginalFilename && (currentPhoto.original_filename || currentPhoto.filename) && (
<p
className="text-xs opacity-60 truncate max-w-[14rem] sm:max-w-md mt-0.5"
title={currentPhoto.original_filename || currentPhoto.filename}
>
{currentPhoto.original_filename || currentPhoto.filename}
</p>
)}
</div>
<div className="flex items-center gap-1 sm:gap-2 flex-wrap justify-end">
@@ -641,9 +699,19 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
aria-label={myLiked ? 'Unlike photo' : 'Like photo'}
title={myLiked ? 'Unlike' : 'Like'}
>
<Heart className={`w-5 h-5 ${myLiked ? 'text-white' : 'text-white'}`} />
{/* fill-current on the liked state so the heart is
actually visible against the red background — both
branches were `text-white` only (#538 follow-on
bug from @Tietge86). */}
<Heart className={`w-5 h-5 text-white ${myLiked ? 'fill-current' : ''}`} />
</button>
<span className="text-white text-xs min-w-[1.5rem] text-center select-none">{likeCount}</span>
{/* Aggregate like count is admin-only when the admin
has hidden feedback from guests (#538 bug 3). Without
this gate, a guest could see how many other guests
liked a photo even with show_feedback_to_guests off. */}
{feedbackSettings?.show_feedback_to_guests && (
<span className="text-white text-xs min-w-[1.5rem] text-center select-none">{likeCount}</span>
)}
</div>
)}
@@ -665,8 +733,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
</div>
)}
{/* Feedback button with indicator */}
{feedbackEnabled && (
{/* Feedback button with indicator. Gated on allow_comments
because likes/ratings already have their own dedicated
toolbar buttons above — this MessageSquare button only
opens the comments panel, so it has nothing to do when
comments are off (#518). */}
{feedbackEnabled && feedbackSettings?.allow_comments && (
<button
onClick={() => {
setShowFeedback(!showFeedback);
@@ -696,20 +768,38 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{(() => {
const isVideoCurrent = currentPhoto.media_type === 'video';
const renderSlide = (photo: Photo | null, isCurrent: boolean) => {
// Stable per-slide keys so React's reconciler can MOVE existing
// DOM nodes across slot positions on commit rather than
// re-fetching the AuthenticatedImage at the new position (#505 —
// that re-fetch is what caused the black blink during swipe).
// Edge case: 2-photo galleries assign the same photo to both
// `prev` and `next`; fall back to slot-prefixed keys to keep
// siblings unique. >2-photo galleries (the common case) get
// plain photo.id keys so a "next becomes current" commit
// preserves the loaded image instance.
const slideKey = (photo: Photo | null, slot: 'prev' | 'current' | 'next') => {
if (!photo) return `empty-${slot}`;
if (photos.length === 2) return `${slot}-${photo.id}`;
return `photo-${photo.id}`;
};
const renderSlide = (photo: Photo | null, isCurrent: boolean, slot: 'prev' | 'current' | 'next') => {
// Reserve the slot even when there's no neighbour (single-photo
// gallery) so the flex layout keeps slides aligned.
if (!photo) {
return <div className="h-full" style={{ flex: '0 0 33.3333%' }} aria-hidden="true" />;
return <div key={slideKey(photo, slot)} className="h-full" style={{ flex: '0 0 33.3333%' }} aria-hidden="true" />;
}
// Neighbouring slides are plain thumbnails — they're only on
// screen during the swipe animation, so we save the work of a
// protected canvas pipeline for them. The current slide keeps
// the full protection chain.
// the full protection chain. Wrapper className matches the
// current slide so object-contain sizing renders the same
// visible height (#505 — earlier `px-2` made wide images
// shorter on neighbours than on current).
if (!isCurrent) {
return (
<div className="h-full flex items-center justify-center px-2" style={{ flex: '0 0 33.3333%' }}>
<div key={slideKey(photo, slot)} className="h-full flex items-center justify-center" style={{ flex: '0 0 33.3333%' }}>
{photo.media_type === 'video' && photo.thumbnail_url ? (
<img
src={photo.thumbnail_url}
@@ -719,7 +809,12 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
/>
) : (
<AuthenticatedImage
src={photo.url}
// Prefer the lightbox preview tier when the admin
// opted in (#492). Falls back to `url` (the
// original) when preview_url is null — happens
// when the toggle is off, when the photo is a
// video, or briefly while lazy generation runs.
src={photo.preview_url || photo.url}
alt={photo.filename}
fallbackSrc={photo.thumbnail_url || undefined}
className="max-w-full max-h-full object-contain select-none pointer-events-none"
@@ -737,12 +832,15 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
return (
<div
key={slideKey(photo, slot)}
className="h-full flex items-center justify-center"
style={{ flex: '0 0 33.3333%' }}
onClick={handleImageClick}
>
<AuthenticatedImage
src={photo.url}
// Same preview-prefer-with-fallback logic as the
// off-screen tile above (#492).
src={photo.preview_url || photo.url}
alt={photo.filename}
fallbackSrc={photo.thumbnail_url || undefined}
className="max-w-full max-h-full object-contain select-none"
@@ -831,9 +929,9 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}}
onTransitionEnd={handleTrackTransitionEnd}
>
{renderSlide(prevPhoto, false)}
{renderSlide(currentPhoto, true)}
{renderSlide(nextPhoto, false)}
{renderSlide(prevPhoto, false, 'prev')}
{renderSlide(currentPhoto, true, 'current')}
{renderSlide(nextPhoto, false, 'next')}
</div>
)}
</div>
@@ -111,8 +111,12 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
onClick={handleLikeClick}
disabled={isSubmitting}
className={`group flex items-center gap-2 px-3 py-2 rounded-lg transition-all ${
isLiked
? 'bg-red-50 text-red-600 hover:bg-red-100'
// Filled solid-red state matches the lightbox toolbar (#538 bug
// 2). The previous `bg-red-50 text-red-600` was almost invisible
// — particularly on dark themes and coloured gallery backgrounds
// — so the user couldn't tell the like had registered.
isLiked
? 'bg-red-500/80 text-white hover:bg-red-500'
: 'bg-surface text-muted-theme hover:bg-black/10'
} ${isSubmitting ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}`}
aria-label={isLiked ? t('feedback.unlike', 'Unlike') : t('feedback.like', 'Like')}
@@ -28,6 +28,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
// bytes-on-wire for that file, so the UI can show "Processing…"
// instead of a static 100% bar while the backend works.
const [processingFiles, setProcessingFiles] = useState<{ [key: string]: boolean }>({});
const [isDragOver, setIsDragOver] = useState(false);
const { data: publicSettings } = usePublicSettings();
@@ -41,11 +42,9 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
[publicSettings?.allowed_file_types]
);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFiles = Array.from(e.target.files || []);
// Validate file types
const validFiles = selectedFiles.filter(file => {
// Shared filter pipeline for both <input> change and drag-and-drop (#504).
const addFiles = (incoming: File[]) => {
const validFiles = incoming.filter((file) => {
if (!allowedMimeTypes.includes(file.type)) {
toast.error(`Invalid file type: ${file.name}`);
return false;
@@ -57,8 +56,38 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
}
return true;
});
if (validFiles.length === 0) return;
setFiles((prev) => [...prev, ...validFiles]);
};
setFiles(prev => [...prev, ...validFiles]);
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
addFiles(Array.from(e.target.files || []));
// Reset so re-selecting the same file fires onChange again.
if (e.target.value) e.target.value = '';
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
e.dataTransfer.dropEffect = 'copy';
if (!isDragOver) setIsDragOver(true);
};
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
// dragleave fires for every child node — only flip off when the cursor
// leaves the zone itself.
if (e.currentTarget.contains(e.relatedTarget as Node | null)) return;
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
if (uploading) return;
addFiles(Array.from(e.dataTransfer.files || []));
};
const removeFile = (index: number) => {
@@ -154,10 +183,18 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
{/* Scrollable Content */}
<div className="flex-1 p-4 sm:p-6 overflow-y-auto min-h-0">
{/* Upload Area */}
{/* Upload Area — accepts both click-to-pick and drag-and-drop (#504). */}
<div className="mb-4 sm:mb-6">
<label className="block">
<div className="border-2 border-dashed border-surface rounded-lg p-6 sm:p-8 text-center hover:border-accent-dark transition-colors cursor-pointer">
<div
className={`border-2 border-dashed rounded-lg p-6 sm:p-8 text-center hover:border-accent-dark transition-colors cursor-pointer ${
isDragOver ? 'border-accent-dark bg-accent-dark/10' : 'border-surface'
}`}
onDragOver={handleDragOver}
onDragEnter={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className="w-10 h-10 sm:w-12 sm:h-12 text-neutral-400 mx-auto mb-3" />
<p className="text-sm font-medium text-muted-theme mb-1">
{t('upload.clickToUpload')}
@@ -36,6 +36,9 @@ export interface BaseGalleryLayoutProps {
// Client visibility controls (#172)
isClient?: boolean;
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
// Mirror of the admin original-filename toggle (#508). Forwarded to the
// lightbox by layouts that mount their own (story/premium).
showOriginalFilename?: boolean;
}
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
@@ -147,7 +147,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<Download className="w-5 h-5" />
</Button>
)}
{feedbackOptions?.allowLikes && (
{feedbackEnabled && feedbackOptions?.allowLikes && (
<Button
variant="ghost"
size="sm"
@@ -6,8 +6,10 @@ import Thumbnails from 'yet-another-react-lightbox/plugins/thumbnails';
import Zoom from 'yet-another-react-lightbox/plugins/zoom';
import Fullscreen from 'yet-another-react-lightbox/plugins/fullscreen';
import Download from 'yet-another-react-lightbox/plugins/download';
import Captions from 'yet-another-react-lightbox/plugins/captions';
import 'yet-another-react-lightbox/styles.css';
import 'yet-another-react-lightbox/plugins/thumbnails.css';
import 'yet-another-react-lightbox/plugins/captions.css';
import { motion, AnimatePresence } from 'framer-motion';
import { Download as DownloadIcon, Heart, Check, Star, MessageSquare, Package, LogOut } from 'lucide-react';
import { useTranslation } from 'react-i18next';
@@ -42,6 +44,10 @@ interface PhotoCardProps {
useEnhancedProtection?: boolean;
useCanvasRendering?: boolean;
feedbackEnabled?: boolean;
// #506: track the per-event "allow likes" toggle so the per-photo
// Like button respects it. `feedbackEnabled` alone isn't enough —
// an event can have feedback on but likes specifically disabled.
allowLikes?: boolean;
index: number;
}
@@ -61,6 +67,7 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
allowLikes = false,
index
}) => {
// Note: height is passed but not used as we maintain aspect ratio via width
@@ -113,15 +120,18 @@ const PhotoCard: React.FC<PhotoCardProps> = ({
{isSelected && <Check className="w-3.5 h-3.5" strokeWidth={3} />}
</button>
{/* Like Button */}
<button
onClick={onLike}
className={`gallery-premium-like-btn ${isLiked ? 'liked' : ''}`}
>
<Heart
className={`w-5 h-5 ${isLiked ? 'fill-current' : ''}`}
/>
</button>
{/* Like Button — #506: only when feedback master is on AND the
per-event "allow likes" sub-toggle is on. */}
{feedbackEnabled && allowLikes && (
<button
onClick={onLike}
className={`gallery-premium-like-btn ${isLiked ? 'liked' : ''}`}
>
<Heart
className={`w-5 h-5 ${isLiked ? 'fill-current' : ''}`}
/>
</button>
)}
{/* Selection Border */}
{isSelected && (
@@ -177,7 +187,8 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
feedbackEnabled = false,
feedbackOptions,
heroPhotoOverride,
onLogout
onLogout,
showOriginalFilename = false,
}) => {
// These props are passed by parent but we use our own lightbox, so mark as intentionally unused
void _onPhotoClick;
@@ -225,16 +236,20 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
}));
}, [filteredPhotos]);
// Lightbox slides
// Lightbox slides. `title` powers the Captions plugin — only emitted
// when the admin has flipped the original-filenames toggle (#508).
const slides = useMemo(() => {
return filteredPhotos.map(photo => ({
src: photo.url,
alt: photo.filename,
width: photo.width || 1200,
height: photo.height || 800,
download: allowDownloads ? photo.url : undefined
download: allowDownloads ? photo.url : undefined,
title: showOriginalFilename
? (photo.original_filename || photo.filename)
: undefined,
}));
}, [filteredPhotos, allowDownloads]);
}, [filteredPhotos, allowDownloads, showOriginalFilename]);
const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => {
e.stopPropagation();
@@ -502,6 +517,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
feedbackEnabled={feedbackEnabled}
allowLikes={!!feedbackOptions?.allowLikes}
index={photoIndex}
/>
);
@@ -528,7 +544,13 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
close={() => setLightboxIndex(-1)}
index={lightboxIndex}
slides={slides}
plugins={allowDownloads ? [Thumbnails, Zoom, Fullscreen, Download] : [Thumbnails, Zoom, Fullscreen]}
plugins={[
Thumbnails,
Zoom,
Fullscreen,
...(allowDownloads ? [Download] : []),
...(showOriginalFilename ? [Captions] : []),
]}
animation={{ fade: 300, swipe: 250 }}
styles={{
container: { backgroundColor: 'rgba(0, 0, 0, 0.95)' },
@@ -58,7 +58,8 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
feedbackOptions,
heroPhotoOverride,
welcomeMessage,
onLogout
onLogout,
showOriginalFilename = false,
}) => {
// These props are passed by parent but we use our own feedback system, so mark as intentionally unused
void _onPhotoClick;
@@ -379,6 +380,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
onFeedbackChange={onFeedbackChange}
showOriginalFilename={showOriginalFilename}
/>
)}
@@ -105,7 +105,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
{feedbackOptions?.allowLikes && (
{feedbackEnabled && feedbackOptions?.allowLikes && (
<button
className={`p-2 rounded-full transition-colors ${likedLocal ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
@@ -144,7 +144,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
{feedbackOptions?.allowLikes && (
{feedbackEnabled && feedbackOptions?.allowLikes && (
<button
className={`p-2 rounded-full transition-colors ${likedIds.has(photo.id) ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
@@ -16,10 +16,13 @@ export interface GeneralSettings {
max_file_size_mb: number;
max_files_per_upload: number;
allowed_file_types: string;
// #509 — re-added after the main-into-beta merge dropped it.
max_upload_batch_size_mb: number;
enable_analytics: boolean;
enable_registration: boolean;
maintenance_mode: boolean;
short_gallery_urls: boolean;
use_original_filenames_for_downloads: boolean;
default_language: string;
date_format: { format: string; locale: string };
}
@@ -51,6 +54,7 @@ export interface EventSettings {
event_require_event_date: boolean;
event_require_expiration: boolean;
event_default_require_password: boolean;
event_default_feedback_enabled: boolean;
gallery_show_filter_bar: boolean;
event_phone_field_enabled: boolean;
}
@@ -69,7 +73,7 @@ export interface SeoSettings {
export function useSettingsState() {
const queryClient = useQueryClient();
const { t, i18n } = useTranslation();
const { t } = useTranslation();
const { updateUserProfile } = useAdminAuth();
// Fetch settings
@@ -90,10 +94,12 @@ export function useSettingsState() {
max_file_size_mb: 50,
max_files_per_upload: 500,
allowed_file_types: 'jpg,jpeg,png,gif,webp',
max_upload_batch_size_mb: 95,
enable_analytics: true,
enable_registration: false,
maintenance_mode: false,
short_gallery_urls: false,
use_original_filenames_for_downloads: false,
default_language: 'en',
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
});
@@ -128,6 +134,7 @@ export function useSettingsState() {
event_require_event_date: true,
event_require_expiration: true,
event_default_require_password: true,
event_default_feedback_enabled: false,
gallery_show_filter_bar: true,
event_phone_field_enabled: false
});
@@ -162,10 +169,6 @@ export function useSettingsState() {
// Initialize settings from API
useEffect(() => {
if (settings) {
if (settings.general_default_language && settings.general_default_language !== i18n.language) {
i18n.changeLanguage(settings.general_default_language);
}
setGeneralSettings({
site_url: settings.general_site_url || '',
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
@@ -175,10 +178,15 @@ export function useSettingsState() {
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
),
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
max_upload_batch_size_mb: toNumber(settings.general_max_upload_batch_size_mb, 95),
enable_analytics: toBoolean(settings.general_enable_analytics, true),
enable_registration: toBoolean(settings.general_enable_registration, false),
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
short_gallery_urls: toBoolean(settings.general_short_gallery_urls, false),
use_original_filenames_for_downloads: toBoolean(
settings.general_use_original_filenames_for_downloads,
false
),
default_language: settings.general_default_language || 'en',
date_format: settings.general_date_format
? (typeof settings.general_date_format === 'string'
@@ -214,6 +222,7 @@ export function useSettingsState() {
event_require_event_date: toBoolean(settings.event_require_event_date, true),
event_require_expiration: toBoolean(settings.event_require_expiration, true),
event_default_require_password: toBoolean(settings.event_default_require_password, true),
event_default_feedback_enabled: toBoolean(settings.event_default_feedback_enabled, false),
gallery_show_filter_bar: toBoolean(settings.gallery_show_filter_bar, true),
event_phone_field_enabled: toBoolean(settings.event_phone_field_enabled, false)
});
@@ -230,7 +239,7 @@ export function useSettingsState() {
sitemap_url: settings.seo_sitemap_url || ''
});
}
}, [settings, i18n]);
}, [settings]);
useEffect(() => {
if (adminProfile) {
@@ -169,6 +169,25 @@ export const EventsTab: React.FC<EventsTabProps> = ({
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_default_feedback_enabled}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_default_feedback_enabled: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('settings.events.defaultFeedbackEnabled', 'Enable Guest Feedback by default')}
</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.events.defaultFeedbackEnabledHelp', 'Pre-check "Guest Feedback" when creating new events. Individual feedback options (likes, ratings, comments) can still be customised per event.')}
</p>
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
@@ -162,6 +162,28 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
</p>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
{t('settings.general.maxUploadBatchSize')}
</label>
<Input
type="number"
value={generalSettings.max_upload_batch_size_mb}
onChange={(e) => {
const parsed = parseInt(e.target.value, 10);
setGeneralSettings(prev => ({
...prev,
max_upload_batch_size_mb: Number.isFinite(parsed)
? Math.max(1, parsed)
: prev.max_upload_batch_size_mb
}));
}}
min="1"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.maxUploadBatchSizeHelp')}
</p>
</div>
</div>
<div>
@@ -229,6 +251,21 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
{t('settings.general.enableShortGalleryUrlsHelp')}
</p>
</div>
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={generalSettings.use_original_filenames_for_downloads}
onChange={(e) => setGeneralSettings(prev => ({ ...prev, use_original_filenames_for_downloads: e.target.checked }))}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<span className="ml-2 text-sm text-neutral-700 dark:text-neutral-300">{t('settings.general.useOriginalFilenames')}</span>
</label>
<p className="text-xs text-neutral-500 dark:text-neutral-400 ml-6 mt-1">
{t('settings.general.useOriginalFilenamesHelp')}
</p>
</div>
</div>
</Card>
@@ -12,6 +12,8 @@ interface ThumbnailSettings {
quality: number;
fit: string;
format: string;
// Lightbox preview tier (#492). Off by default; admin opts in.
lightbox_preview_enabled: boolean;
}
const defaultSettings: ThumbnailSettings = {
@@ -20,8 +22,20 @@ const defaultSettings: ThumbnailSettings = {
quality: 85,
fit: 'cover',
format: 'jpeg',
lightbox_preview_enabled: false,
};
// Backend returns the lightbox toggle as a JSON-stringified boolean
// per migration 104. Tolerate raw boolean / "true" / "false" / "1" /
// "0" coming back so the form mirrors whatever shape lands.
function parseLightboxFlag(raw: unknown): boolean {
if (raw === true || raw === 1) return true;
if (typeof raw !== 'string') return false;
const trimmed = raw.trim().toLowerCase();
if (trimmed === 'true' || trimmed === '"true"' || trimmed === '1') return true;
return false;
}
interface FetchedSettings {
settings: Record<string, { value: string; description: string }>;
fitOptions: Array<'cover' | 'contain' | 'fill' | 'inside' | 'outside'>;
@@ -51,6 +65,7 @@ export const ThumbnailsTab: React.FC = () => {
quality: parseInt(s.thumbnail_quality?.value) || defaultSettings.quality,
fit: s.thumbnail_fit?.value || defaultSettings.fit,
format: s.thumbnail_format?.value || defaultSettings.format,
lightbox_preview_enabled: parseLightboxFlag(s.lightbox_preview_enabled?.value),
});
}
}, [fetchedData]);
@@ -83,6 +98,23 @@ export const ThumbnailsTab: React.FC = () => {
},
});
// Lightbox preview tier (#492). Eager regeneration counterpart to
// ensurePreviewImage's lazy on-first-open generation. Useful after
// flipping the toggle on so guests don't pay the lazy-cost on the
// very first lightbox open per gallery.
const regeneratePreviewsMutation = useMutation({
mutationFn: async () => {
const response = await api.post('/admin/thumbnails/regenerate-previews');
return response.data;
},
onSuccess: (data) => {
toast.success(data.message || t('settings.thumbnails.previewsRegenerateStarted', 'Lightbox preview regeneration started'));
},
onError: () => {
toast.error(t('settings.thumbnails.previewsRegenerateError', 'Failed to start preview regeneration'));
},
});
const handleChange = <K extends keyof ThumbnailSettings>(
key: K,
value: ThumbnailSettings[K]
@@ -104,6 +136,7 @@ export const ThumbnailsTab: React.FC = () => {
quality: parseInt(s.thumbnail_quality?.value) || defaultSettings.quality,
fit: s.thumbnail_fit?.value || defaultSettings.fit,
format: s.thumbnail_format?.value || defaultSettings.format,
lightbox_preview_enabled: parseLightboxFlag(s.lightbox_preview_enabled?.value),
});
setIsDirty(false);
}
@@ -255,6 +288,51 @@ export const ThumbnailsTab: React.FC = () => {
</Button>
</Card>
{/* Lightbox preview tier (#492). Independent opt-in from the
thumbnail size/quality settings above — costs disk but
dramatically speeds up lightbox open on mobile / slow
connections by serving an aspect-preserved ~1920px JPEG
instead of the multi-megabyte original. */}
<Card padding="md">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-1 flex items-center gap-2">
<Image className="w-5 h-5 text-primary-600" />
{t('settings.thumbnails.lightboxTitle', 'Lightbox Preview Tier')}
</h2>
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
{t('settings.thumbnails.lightboxHelp', 'When enabled, the lightbox loads an aspect-preserved ~1920px JPEG (typically 200500 KB) instead of the full original (often 512 MB). Originals are still served when guests click Download. Costs roughly one extra preview file per photo on disk; previews are generated lazily on first open and stored in /previews.')}
</p>
<label className="flex items-start gap-3 cursor-pointer mb-4">
<input
type="checkbox"
className="mt-0.5 rounded border-neutral-300 dark:border-neutral-600 text-accent focus:ring-primary-500"
checked={settings.lightbox_preview_enabled}
onChange={(e) => handleChange('lightbox_preview_enabled', e.target.checked)}
/>
<span className="text-sm">
<span className="font-medium text-neutral-900 dark:text-neutral-100">
{t('settings.thumbnails.lightboxToggle', 'Use medium-resolution previews in the lightbox')}
</span>
<span className="block text-xs text-neutral-600 dark:text-neutral-400 mt-0.5">
{t('settings.thumbnails.lightboxToggleHelp', 'Off by default. Flip on after deciding the perceived-perf win is worth the extra disk usage.')}
</span>
</span>
</label>
{/* Eager regeneration so guests don't pay the lazy first-open
cost. Only useful after the toggle is on; gated so admins
don't accidentally kick off a job that won't be visible. */}
<Button
variant="outline"
onClick={() => regeneratePreviewsMutation.mutate()}
isLoading={regeneratePreviewsMutation.isPending}
disabled={!settings.lightbox_preview_enabled}
leftIcon={regeneratePreviewsMutation.isPending ? <Loader2 className="w-5 h-5 animate-spin" /> : <RefreshCw className="w-5 h-5" />}
>
{t('settings.thumbnails.regeneratePreviewsButton', 'Regenerate All Previews')}
</Button>
</Card>
{/* Info Box */}
<Card padding="md" className="bg-blue-50 dark:bg-blue-900/30 border-blue-200 dark:border-blue-800">
<div className="flex items-start gap-3">
+29
View File
@@ -65,6 +65,35 @@ export const useDownloadPhoto = () => {
});
};
// Save-aware download — opens the OS share sheet on mobile (so "Save to
// Photos" lands the file in the Photos/Gallery app instead of Files),
// falls back to a regular download on browsers without Web Share file
// support. See galleryService.savePhotoToDevice for the negotiation
// (#531).
//
// Toast omitted on success because the share-sheet path doesn't really
// finish from this code's perspective — the OS UI takes over and the
// user picks where it goes. Showing "Photo downloaded" before they've
// even picked is misleading. The fallback download path is also silent
// to keep the two paths symmetrical; the file appearing in Downloads
// is its own affordance.
export const useSavePhotoToDevice = () => {
return useMutation({
mutationFn: ({
slug,
photoId,
filename,
}: {
slug: string;
photoId: number;
filename: string;
}) => galleryService.savePhotoToDevice(slug, photoId, filename),
onError: () => {
toast.error('Failed to save photo');
},
});
};
export const useDownloadAllPhotos = () => {
return useMutation({
mutationFn: ({ slug, zipReady }: { slug: string; zipReady?: boolean }) =>
+17
View File
@@ -1031,6 +1031,8 @@
"maxFileSize": "Max. Dateigröße (MB)",
"maxFilesPerUpload": "Max. Dateien pro Upload",
"maxFilesPerUploadHelp": "Maximale Anzahl an Fotos pro Upload-Vorgang (1-{{max}}).",
"maxUploadBatchSize": "Max. Upload-Paketgröße (MB)",
"maxUploadBatchSizeHelp": "Maximale Größe pro Upload-Anfrage. Reduzieren Sie diesen Wert bei Nutzung eines Reverse-Proxys mit Größenbeschränkung (z.B. Cloudflare: 100MB).",
"allowedFileTypes": "Erlaubte Dateitypen",
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
"featureToggles": "Funktionsschalter",
@@ -1038,6 +1040,8 @@
"enableRegistration": "Selbstregistrierung für Admins erlauben",
"enableShortGalleryUrls": "Kurze Galerie-Links verwenden",
"enableShortGalleryUrlsHelp": "Entfernt den Veranstaltungs-Slug aus neuen Freigabelinks und lässt bestehende Links weiterhin funktionieren.",
"useOriginalFilenames": "Originale Dateinamen beim Download verwenden",
"useOriginalFilenamesHelp": "Wenn aktiviert, verwenden Einzel- und ZIP-Downloads den Original-Dateinamen der Kamera (z. B. DSC_1234.jpg) statt des umbenannten Namens. Der Speicher bleibt unverändert; Duplikate innerhalb einer Veranstaltung erhalten ein numerisches Suffix.",
"maintenanceMode": "Wartungsmodus aktivieren",
"language": "Sprache",
"defaultLanguageHelp": "Sprache, die Gästen vor der Anmeldung angezeigt wird",
@@ -1242,6 +1246,13 @@
"regenerateButton": "Alle Vorschaubilder neu generieren",
"regenerateStarted": "Neugenerierung der Vorschaubilder gestartet",
"regenerateError": "Neugenerierung der Vorschaubilder konnte nicht gestartet werden",
"lightboxTitle": "Lightbox-Vorschau-Stufe",
"lightboxHelp": "Wenn aktiviert, lädt die Lightbox ein seitenverhältnis-erhaltendes JPEG mit ~1920 px (typischerweise 200500 KB) statt des vollen Originals (oft 512 MB). Beim Download durch Gäste wird weiterhin das Original ausgeliefert. Kostet pro Foto eine zusätzliche Vorschaudatei auf der Festplatte; Vorschauen werden beim ersten Öffnen erzeugt und unter /previews gespeichert.",
"lightboxToggle": "Mittelauflösende Vorschauen in der Lightbox verwenden",
"lightboxToggleHelp": "Standardmäßig deaktiviert. Aktivieren, sobald der gefühlte Geschwindigkeitsgewinn den zusätzlichen Speicherbedarf rechtfertigt.",
"regeneratePreviewsButton": "Alle Vorschauen neu generieren",
"previewsRegenerateStarted": "Neugenerierung der Lightbox-Vorschauen gestartet",
"previewsRegenerateError": "Neugenerierung der Vorschauen konnte nicht gestartet werden",
"saveSuccess": "Vorschaubild-Einstellungen gespeichert",
"saveError": "Vorschaubild-Einstellungen konnten nicht gespeichert werden",
"loadError": "Vorschaubild-Einstellungen konnten nicht geladen werden",
@@ -1792,6 +1803,12 @@
"sizeMedium": "Mittel (Standard)",
"sizeLarge": "Groß",
"sizeXLarge": "Sehr groß"
},
"promo": {
"alignment": "Ausrichtung",
"alignLeft": "Links",
"alignCenter": "Zentriert (Standard wie der Footer)",
"alignRight": "Rechts"
}
},
"admin": {
+17
View File
@@ -670,6 +670,8 @@
"maxFileSize": "Max File Size (MB)",
"maxFilesPerUpload": "Max Files per Upload",
"maxFilesPerUploadHelp": "Maximum number of photos allowed in a single upload batch (1-{{max}}).",
"maxUploadBatchSize": "Max Upload Batch Size (MB)",
"maxUploadBatchSizeHelp": "Maximum size per upload request. Lower this if behind a reverse proxy with request size limits (e.g. Cloudflare: 100MB).",
"allowedFileTypes": "Allowed File Types",
"allowedFileTypesHelp": "Comma-separated list of file extensions",
"featureToggles": "Feature Toggles",
@@ -677,6 +679,8 @@
"enableRegistration": "Allow self-registration for admins",
"enableShortGalleryUrls": "Use short gallery URLs",
"enableShortGalleryUrlsHelp": "Removes the event slug from new share links while keeping existing links working.",
"useOriginalFilenames": "Use original filenames on download",
"useOriginalFilenamesHelp": "When on, single-photo and ZIP downloads use the original camera filename (e.g. DSC_1234.jpg) instead of the sanitized name. Storage is unchanged; duplicates in the same event get a numeric suffix.",
"maintenanceMode": "Enable maintenance mode",
"language": "Language",
"defaultLanguageHelp": "Language shown to guests before login",
@@ -948,6 +952,13 @@
"regenerateButton": "Regenerate All Thumbnails",
"regenerateStarted": "Thumbnail regeneration started",
"regenerateError": "Failed to start thumbnail regeneration",
"lightboxTitle": "Lightbox Preview Tier",
"lightboxHelp": "When enabled, the lightbox loads an aspect-preserved ~1920px JPEG (typically 200500 KB) instead of the full original (often 512 MB). Originals are still served when guests click Download. Costs roughly one extra preview file per photo on disk; previews are generated lazily on first open and stored in /previews.",
"lightboxToggle": "Use medium-resolution previews in the lightbox",
"lightboxToggleHelp": "Off by default. Flip on after deciding the perceived-perf win is worth the extra disk usage.",
"regeneratePreviewsButton": "Regenerate All Previews",
"previewsRegenerateStarted": "Lightbox preview regeneration started",
"previewsRegenerateError": "Failed to start preview regeneration",
"saveSuccess": "Thumbnail settings saved",
"saveError": "Failed to save thumbnail settings",
"loadError": "Failed to load thumbnail settings",
@@ -1462,6 +1473,12 @@
"sizeMedium": "Medium (default)",
"sizeLarge": "Large",
"sizeXLarge": "Extra large"
},
"promo": {
"alignment": "Alignment",
"alignLeft": "Left",
"alignCenter": "Center (default — matches footer)",
"alignRight": "Right"
}
},
"admin": {
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+6
View File
@@ -1462,6 +1462,12 @@
"sizeMedium": "Middel (standaard)",
"sizeLarge": "Groot",
"sizeXLarge": "Extra groot"
},
"promo": {
"alignment": "Uitlijning",
"alignLeft": "Links",
"alignCenter": "Gecentreerd (standaard — komt overeen met footer)",
"alignRight": "Rechts"
}
},
"admin": {
+6
View File
@@ -1479,6 +1479,12 @@
"sizeMedium": "Médio (padrão)",
"sizeLarge": "Grande",
"sizeXLarge": "Extra grande"
},
"promo": {
"alignment": "Alinhamento",
"alignLeft": "Esquerda",
"alignCenter": "Centro (padrão — igual ao rodapé)",
"alignRight": "Direita"
}
},
"admin": {
+6
View File
@@ -1496,6 +1496,12 @@
"sizeMedium": "Средний (по умолчанию)",
"sizeLarge": "Большой",
"sizeXLarge": "Очень большой"
},
"promo": {
"alignment": "Выравнивание",
"alignLeft": "По левому краю",
"alignCenter": "По центру (по умолчанию — как в подвале)",
"alignRight": "По правому краю"
}
},
"admin": {
+39 -13
View File
@@ -43,6 +43,7 @@ export const BrandingPage: React.FC = () => {
youtube_url: '',
promo_markdown: '',
promo_position: 'above_footer',
promo_alignment: 'center',
});
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
@@ -420,18 +421,36 @@ export const BrandingPage: React.FC = () => {
{t('branding.promo.help', 'Markdown shown above or below the gallery footer (e.g. seasonal offer, print discount). Per-event overrides take priority.')}
</p>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.promo.position', 'Position')}
</label>
<select
value={brandingSettings.promo_position || 'above_footer'}
onChange={(e) => handleBrandingChange('promo_position', e.target.value as 'above_footer' | 'below_footer')}
className="w-full sm:w-64 px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="above_footer">{t('branding.promo.aboveFooter', 'Above footer')}</option>
<option value="below_footer">{t('branding.promo.belowFooter', 'Below footer')}</option>
</select>
<div className="flex flex-col sm:flex-row gap-3">
<div className="flex-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.promo.position', 'Position')}
</label>
<select
value={brandingSettings.promo_position || 'above_footer'}
onChange={(e) => handleBrandingChange('promo_position', e.target.value as 'above_footer' | 'below_footer')}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="above_footer">{t('branding.promo.aboveFooter', 'Above footer')}</option>
<option value="below_footer">{t('branding.promo.belowFooter', 'Below footer')}</option>
</select>
</div>
{/* Horizontal alignment (#482). Defaults to center
so the banner aligns with the gallery footer. */}
<div className="flex-1">
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.promo.alignment', 'Alignment')}
</label>
<select
value={brandingSettings.promo_alignment || 'center'}
onChange={(e) => handleBrandingChange('promo_alignment', e.target.value as 'left' | 'center' | 'right')}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-lg focus:ring-2 focus:ring-primary-500"
>
<option value="left">{t('branding.promo.alignLeft', 'Left')}</option>
<option value="center">{t('branding.promo.alignCenter', 'Center (default — matches footer)')}</option>
<option value="right">{t('branding.promo.alignRight', 'Right')}</option>
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
@@ -453,9 +472,16 @@ export const BrandingPage: React.FC = () => {
<div className="text-xs uppercase tracking-wider text-neutral-500 dark:text-neutral-400 mb-2">
{t('branding.promo.preview', 'Preview')}
</div>
{/* Preview mirrors the live gallery render same
alignment class so the admin sees what guests
will see (#482). */}
<MarkdownContent
source={brandingSettings.promo_markdown}
className="text-sm text-neutral-800 dark:text-neutral-200 prose-sm prose-a:text-primary-600 dark:prose-a:text-primary-400"
className={`text-sm text-neutral-800 dark:text-neutral-200 prose prose-sm prose-a:text-primary-600 dark:prose-a:text-primary-400 ${
brandingSettings.promo_alignment === 'left' ? 'text-left'
: brandingSettings.promo_alignment === 'right' ? 'text-right'
: 'text-center'
}`}
/>
</div>
)}
+1 -1
View File
@@ -303,7 +303,7 @@ export const CMSPage: React.FC = () => {
const branding = publicSiteBranding || publicSiteDefaults?.branding;
const substitutedHtml = applyBrandTokens(publicSiteSanitizedHtml, branding);
const inlineStyles = [
branding ? `:root {\n --brand-primary: ${branding.colors.primary};\n --brand-accent: ${branding.colors.accent};\n --brand-background: ${branding.colors.background};\n --brand-text: ${branding.colors.text};\n}` : '',
branding ? `:root {\n --brand-primary: ${branding.colors.primary};\n --brand-accent: ${branding.colors.accent};\n --brand-background: ${branding.colors.background};\n --brand-text: ${branding.colors.text};\n --brand-surface: ${branding.colors.surface || '#ffffff'};\n --brand-elevated: ${branding.colors.elevated || '#f5f5f5'};\n --brand-border: ${branding.colors.border || '#e5e5e5'};\n --brand-muted-text: ${branding.colors.mutedText || '#737373'};\n}` : '',
publicSiteBaseCss,
publicSiteSanitizedCss ? `/* Custom styles */\n${publicSiteSanitizedCss}` : ''
].filter(Boolean).join('\n\n');
@@ -246,6 +246,25 @@ export const CreateEventPage: React.FC = () => {
}));
}, [publicSettings]);
// Honour the global "Enable Guest Feedback by default" admin setting (#520).
// Same one-shot apply pattern as require_password above — only seeds the
// master toggle. The sub-toggles (likes / ratings / comments) keep their
// hard-coded true defaults so a flipped master immediately gives sensible
// behaviour without a second admin setting to manage.
const feedbackEnabledDefaultApplied = useRef(false);
useEffect(() => {
if (feedbackEnabledDefaultApplied.current) return;
if (publicSettings?.event_default_feedback_enabled === undefined) return;
feedbackEnabledDefaultApplied.current = true;
setFormData(prev => ({
...prev,
feedback_settings: {
...prev.feedback_settings,
feedback_enabled: publicSettings.event_default_feedback_enabled === true
}
}));
}, [publicSettings]);
// Apply the global Branding default theme on first load so admins who set a
// site-wide default in Branding actually see it on new events (#323).
// This is the "always inherit colours from Branding" guarantee — every new
@@ -20,6 +20,7 @@ import {
import { format } from 'date-fns';
import { Button, Card, Input, Loading } from '../../components/common';
import { SUPPORTED_LANGUAGES } from '../../components/common/LanguageSelector';
import { AssignedEventsDialog } from '../../components/admin/AssignedEventsDialog';
import {
customerAdminService,
@@ -227,11 +228,12 @@ export const CustomerDetailPage: React.FC = () => {
onChange={setField('preferredLanguage')}
className="input"
>
<option value="en">English</option>
<option value="de">Deutsch</option>
<option value="nl">Nederlands</option>
<option value="pt">Português</option>
<option value="ru">Русский</option>
{/* Drive the option list from SUPPORTED_LANGUAGES so adding a
locale (#510 added es; fr was already missing here) only
needs to touch LanguageSelector. */}
{SUPPORTED_LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>{lang.name}</option>
))}
</select>
</div>
</div>
+27 -4
View File
@@ -372,6 +372,12 @@ export const EventDetailsPage: React.FC = () => {
const [logoUploading, setLogoUploading] = useState(false);
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
// Tracks whether the admin actually interacted with the theme picker
// during this edit session. Prevents the save handler from writing the
// initial display state back to `events.color_theme`, which silently
// overwrote branding inheritance on events with a NULL color_theme
// (API-created events — #550 follow-up).
const [themeChanged, setThemeChanged] = useState(false);
const [cssTemplates, setCssTemplates] = useState<EnabledTemplate[]>([]);
// Fetch CSS templates when component mounts or editing starts
@@ -643,10 +649,20 @@ export const EventDetailsPage: React.FC = () => {
setCurrentPresetName('default');
}
} else {
setCurrentTheme(GALLERY_THEME_PRESETS.default.config);
setCurrentPresetName('default');
// No color_theme stored — the gallery renders with the site
// branding theme as a fallback. Mirror that here so the picker
// shows the same palette the admin sees on the gallery, rather
// than the hardcoded Classic Grid preset that has nothing to do
// with their branding (#550 follow-up). currentPresetName=custom
// because the inherited config isn't a named preset; combined
// with themeChanged=false below, saving without touching the
// picker leaves color_theme NULL and preserves inheritance.
const branding = publicSettings?.theme_config as ThemeConfig | undefined;
setCurrentTheme(branding ?? GALLERY_THEME_PRESETS.default.config);
setCurrentPresetName(branding ? 'custom' : 'default');
}
setThemeChanged(false);
setIsEditing(true);
};
@@ -765,7 +781,11 @@ export const EventDetailsPage: React.FC = () => {
if (editForm.welcome_message !== undefined && editForm.welcome_message !== null) {
updateData.welcome_message = editForm.welcome_message;
}
if (themeToSave) {
// Only persist color_theme when the admin actually interacted with
// the picker. Writing the initial display state back to the row
// silently overwrote NULL (= "inherit branding") with the picker's
// default preset on any save (#550 follow-up).
if (themeChanged && themeToSave) {
updateData.color_theme = themeToSave;
}
if (editForm.upload_category_id !== undefined) {
@@ -2210,10 +2230,12 @@ export const EventDetailsPage: React.FC = () => {
onChange={(theme) => {
setCurrentTheme(theme);
setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(theme) }));
setThemeChanged(true);
}}
presetName={currentPresetName}
onPresetChange={(presetName) => {
setCurrentPresetName(presetName);
setThemeChanged(true);
if (presetName !== 'custom') {
const preset = GALLERY_THEME_PRESETS[presetName];
if (preset) {
@@ -2248,6 +2270,7 @@ export const EventDetailsPage: React.FC = () => {
setCurrentTheme(merged);
setCurrentPresetName('custom');
setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(merged) }));
setThemeChanged(true);
toast.success(t('toast.brandingPaletteSynced', 'Palette synced from Branding.'));
}}
isPreviewMode={true}
+31 -4
View File
@@ -9,6 +9,30 @@ import { cmsService } from '../../services/cms.service';
import { usePublicSettings } from '../../hooks/usePublicSettings';
import '../../styles/prose-overrides.css';
// Force rel="noopener noreferrer" on target="_blank" anchors in CMS-authored
// HTML so editors can't accidentally (or maliciously) introduce reverse
// tabnabbing via the legal pages.
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (node.tagName === 'A' && node.getAttribute('target') === '_blank') {
node.setAttribute('rel', 'noopener noreferrer');
}
});
// CMS-configured external_url may be edited by lower-privileged staff; reject
// anything outside http(s) so the legal route can't be turned into a
// javascript:/data: launcher.
const sanitizeExternalUrl = (url: string): string | null => {
try {
const parsed = new URL(url, window.location.origin);
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
return null;
}
return parsed.toString();
} catch {
return null;
}
};
export const LegalPage: React.FC = () => {
const { slug } = useParams<{ slug: string }>();
const { i18n } = useTranslation();
@@ -47,12 +71,15 @@ export const LegalPage: React.FC = () => {
// External-URL override: full-page redirect so the visitor lands on the
// operator's own canonical legal page. Use replace() so the back button
// returns to the gallery instead of looping back through the redirect.
const willRedirect = !!(page?.use_external_url && page?.external_url);
const safeExternalUrl = page?.use_external_url && page?.external_url
? sanitizeExternalUrl(page.external_url)
: null;
const willRedirect = !!safeExternalUrl;
useEffect(() => {
if (willRedirect && page?.external_url) {
window.location.replace(page.external_url);
if (safeExternalUrl) {
window.location.replace(safeExternalUrl);
}
}, [willRedirect, page?.external_url]);
}, [safeExternalUrl]);
if (isLoading || willRedirect) {
return (
@@ -0,0 +1,204 @@
/**
* Coverage for #557 iOS Web Share path on multi-photo selection.
*
* downloadSelectedPhotos historically POSTed to /download-selected and
* triggered a zip download. On iOS with a small selection it now routes
* through navigator.share({ files }) so the photos land in Photos via
* the share sheet's "Save N Images" action. Above the file-count cap
* or anywhere off-iOS, behaviour is unchanged.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15';
const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/120.0.0.0';
const fetchedFor = (id: number) => ({
blob: new Blob([`photo-${id}`], { type: 'image/jpeg' }),
serverFilename: `IMG_${String(id).padStart(4, '0')}.jpg`,
});
// Mock the axios layer so the test never makes a network call. The
// real api.post resolves with { data: Blob } for the zip path; the
// shape only matters when the fallback branch is exercised.
vi.mock('../../config/api', () => ({
api: {
post: vi.fn(),
get: vi.fn(),
},
}));
let galleryService: typeof import('../gallery.service').galleryService;
let apiMock: { post: ReturnType<typeof vi.fn>; get: ReturnType<typeof vi.fn> };
const installNavigator = (overrides: Partial<{
userAgent: string;
platform: string;
maxTouchPoints: number;
share: ReturnType<typeof vi.fn>;
canShare: ReturnType<typeof vi.fn>;
}>) => {
const desc = (value: any) => ({ value, configurable: true, writable: true });
Object.defineProperties(navigator, {
userAgent: desc(overrides.userAgent ?? ''),
platform: desc(overrides.platform ?? ''),
maxTouchPoints: desc(overrides.maxTouchPoints ?? 0),
});
(navigator as any).share = overrides.share;
(navigator as any).canShare = overrides.canShare;
};
describe('galleryService.downloadSelectedPhotos — iOS Web Share path (#557)', () => {
beforeEach(async () => {
vi.resetModules();
const services = await import('../gallery.service');
galleryService = services.galleryService;
apiMock = (await import('../../config/api')).api as any;
apiMock.post.mockReset();
apiMock.post.mockResolvedValue({ data: new Blob(['zip-bytes'], { type: 'application/zip' }) });
// jsdom doesn't ship URL.createObjectURL / revokeObjectURL —
// the zip-fallback path needs both to materialise the <a> link.
(window.URL.createObjectURL as any) = vi.fn(() => 'blob:fake');
(window.URL.revokeObjectURL as any) = vi.fn();
// fetchPhotoBlob is the network-dependent helper; stub it across
// every test so we never touch the real download endpoint.
vi.spyOn(galleryService, 'fetchPhotoBlob').mockImplementation((_slug, id) =>
Promise.resolve(fetchedFor(id) as any),
);
});
afterEach(() => {
vi.restoreAllMocks();
delete (navigator as any).share;
delete (navigator as any).canShare;
});
it('routes through navigator.share on iOS with a small selection', async () => {
const share = vi.fn().mockResolvedValue(undefined);
const canShare = vi.fn().mockReturnValue(true);
installNavigator({ userAgent: IOS_UA, share, canShare });
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
expect(share).toHaveBeenCalledTimes(1);
const shareArg = share.mock.calls[0][0];
expect(shareArg.files).toHaveLength(3);
expect((shareArg.files[0] as File).name).toBe('IMG_0001.jpg');
// Zip endpoint must NOT be called when share succeeds.
expect(apiMock.post).not.toHaveBeenCalled();
});
it('falls back to the zip endpoint on Android, even with canShare available', async () => {
const share = vi.fn().mockResolvedValue(undefined);
const canShare = vi.fn().mockReturnValue(true);
installNavigator({ userAgent: ANDROID_UA, share, canShare });
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
expect(share).not.toHaveBeenCalled();
expect(apiMock.post).toHaveBeenCalledTimes(1);
expect(apiMock.post).toHaveBeenCalledWith(
'/gallery/wedding-2026/download-selected',
{ photo_ids: [1, 2, 3] },
{ responseType: 'blob' },
);
});
it('falls back to the zip endpoint above the 25-file cap', async () => {
// 26 photos: even on iOS, this exceeds MAX_WEB_SHARE_FILES so the
// Web Share path is skipped entirely (no fetchPhotoBlob calls,
// no share() call, no canShare() probe).
const share = vi.fn();
const canShare = vi.fn();
installNavigator({ userAgent: IOS_UA, share, canShare });
const ids = Array.from({ length: 26 }, (_, i) => i + 1);
await galleryService.downloadSelectedPhotos('wedding-2026', ids);
expect(galleryService.fetchPhotoBlob).not.toHaveBeenCalled();
expect(share).not.toHaveBeenCalled();
expect(apiMock.post).toHaveBeenCalledTimes(1);
});
it('takes the Web Share path at exactly the 25-file boundary', async () => {
const share = vi.fn().mockResolvedValue(undefined);
const canShare = vi.fn().mockReturnValue(true);
installNavigator({ userAgent: IOS_UA, share, canShare });
const ids = Array.from({ length: 25 }, (_, i) => i + 1);
await galleryService.downloadSelectedPhotos('wedding-2026', ids);
expect(share).toHaveBeenCalledTimes(1);
expect(apiMock.post).not.toHaveBeenCalled();
});
it('does NOT fall back to the zip endpoint when the user dismisses the share sheet (AbortError)', async () => {
const abortErr = Object.assign(new Error('user dismissed'), { name: 'AbortError' });
const share = vi.fn().mockRejectedValue(abortErr);
const canShare = vi.fn().mockReturnValue(true);
installNavigator({ userAgent: IOS_UA, share, canShare });
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
expect(share).toHaveBeenCalledTimes(1);
// A surprise zip in Downloads would defeat the user's deliberate
// dismissal of the share sheet.
expect(apiMock.post).not.toHaveBeenCalled();
});
it('falls back to the zip endpoint when share() rejects with a non-Abort error', async () => {
const notAllowed = Object.assign(new Error('blocked'), { name: 'NotAllowedError' });
const share = vi.fn().mockRejectedValue(notAllowed);
const canShare = vi.fn().mockReturnValue(true);
installNavigator({ userAgent: IOS_UA, share, canShare });
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
expect(apiMock.post).toHaveBeenCalledTimes(1);
});
it('falls back to the zip endpoint when canShare({files}) returns false (older iOS)', async () => {
const share = vi.fn();
const canShare = vi.fn().mockReturnValue(false);
installNavigator({ userAgent: IOS_UA, share, canShare });
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
expect(share).not.toHaveBeenCalled();
expect(apiMock.post).toHaveBeenCalledTimes(1);
});
it('falls back to the zip endpoint when any photo fetch fails (partial shares would be confusing)', async () => {
const share = vi.fn();
const canShare = vi.fn().mockReturnValue(true);
installNavigator({ userAgent: IOS_UA, share, canShare });
// Re-stub fetchPhotoBlob so the 2nd of 3 photos fails — the Promise.all
// collapse must route the whole batch to the zip endpoint rather
// than sharing only the photos that resolved.
(galleryService.fetchPhotoBlob as any).mockReset();
(galleryService.fetchPhotoBlob as any)
.mockResolvedValueOnce(fetchedFor(1))
.mockRejectedValueOnce(new Error('network'))
.mockResolvedValueOnce(fetchedFor(3));
await galleryService.downloadSelectedPhotos('wedding-2026', [1, 2, 3]);
expect(share).not.toHaveBeenCalled();
expect(apiMock.post).toHaveBeenCalledTimes(1);
});
it('falls back to the zip endpoint when the selection is empty (no Web Share invocation)', async () => {
const share = vi.fn();
const canShare = vi.fn();
installNavigator({ userAgent: IOS_UA, share, canShare });
await galleryService.downloadSelectedPhotos('wedding-2026', []);
expect(galleryService.fetchPhotoBlob).not.toHaveBeenCalled();
expect(share).not.toHaveBeenCalled();
expect(apiMock.post).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,171 @@
/**
* Regression coverage for issue #554.
*
* `savePhotoToDevice` originally (PR #531) routed through navigator.share
* whenever `canShare({files})` returned true, on the assumption that any
* mobile share sheet would expose a "Save Image" action. That's only true
* on iOS Android's share sheet only lists installed apps that handle
* image/* intents, so the user gets an app-picker instead of a save
* dialog. These tests pin the iOS-only gating: iOS goes through Web Share,
* everywhere else falls through to <a download>.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15';
const IPADOS_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15';
const ANDROID_UA = 'Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 Chrome/120.0.0.0';
const DESKTOP_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/17.0 Safari/605.1.15';
// Stub the photo blob fetch + the <a download> trigger so the test
// only exercises the iOS-vs-other branching logic. fetchPhotoBlob is
// network; triggerBrowserDownload calls document.createElement + click
// which is side-effecty in jsdom (and not what we're testing here).
const fetchedBlob = { blob: new Blob(['x'], { type: 'image/jpeg' }), serverFilename: 'IMG_0001.jpg' };
let galleryService: typeof import('../gallery.service').galleryService;
const installNavigator = (overrides: Partial<{
userAgent: string;
platform: string;
maxTouchPoints: number;
share: ReturnType<typeof vi.fn>;
canShare: ReturnType<typeof vi.fn>;
}>) => {
const desc = (value: any) => ({ value, configurable: true, writable: true });
Object.defineProperties(navigator, {
userAgent: desc(overrides.userAgent ?? ''),
platform: desc(overrides.platform ?? ''),
maxTouchPoints: desc(overrides.maxTouchPoints ?? 0),
});
// share / canShare don't exist on jsdom's navigator by default, so
// they're plain assignments rather than defineProperty.
(navigator as any).share = overrides.share;
(navigator as any).canShare = overrides.canShare;
};
describe('galleryService.savePhotoToDevice — iOS gating (#554)', () => {
beforeEach(async () => {
vi.resetModules();
galleryService = (await import('../gallery.service')).galleryService;
vi.spyOn(galleryService, 'fetchPhotoBlob').mockResolvedValue(fetchedBlob as any);
vi.spyOn(galleryService, 'triggerBrowserDownload').mockImplementation(() => undefined);
vi.spyOn(galleryService, 'triggerDirectDownload').mockImplementation(() => undefined);
});
afterEach(() => {
vi.restoreAllMocks();
delete (navigator as any).share;
delete (navigator as any).canShare;
});
it('routes through navigator.share on iOS when canShare({files}) is true', async () => {
const share = vi.fn().mockResolvedValue(undefined);
const canShare = vi.fn().mockReturnValue(true);
installNavigator({ userAgent: IOS_UA, share, canShare });
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
expect(share).toHaveBeenCalledTimes(1);
expect(canShare).toHaveBeenCalledWith({ files: expect.any(Array) });
expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled();
expect(galleryService.triggerDirectDownload).not.toHaveBeenCalled();
});
it('navigates straight to the download URL on Android (no blob round-trip)', async () => {
// #554 fix: canShare is true on Chrome Android but the share sheet
// has no "Save Image" action, so the share path is iOS-only. The
// follow-up issue (Rekoo-PS, post-#556) was that the Android
// fallback fetched the blob through JS before clicking <a download>,
// adding ~5s of dead air before the browser's download UI appeared
// and prompting users to re-click. Going straight to the download
// URL hands the fetch to the browser, which shows its own progress
// immediately — no spinner needed.
const share = vi.fn().mockResolvedValue(undefined);
const canShare = vi.fn().mockReturnValue(true);
installNavigator({ userAgent: ANDROID_UA, share, canShare });
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
expect(share).not.toHaveBeenCalled();
expect(galleryService.fetchPhotoBlob).not.toHaveBeenCalled();
expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled();
expect(galleryService.triggerDirectDownload).toHaveBeenCalledTimes(1);
expect(galleryService.triggerDirectDownload).toHaveBeenCalledWith(
expect.stringMatching(/\/gallery\/slug\/download\/1$/),
'fallback.jpg',
);
});
it('navigates straight to the download URL on desktop Safari (no share / canShare APIs)', async () => {
installNavigator({ userAgent: DESKTOP_UA });
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
expect(galleryService.fetchPhotoBlob).not.toHaveBeenCalled();
expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled();
expect(galleryService.triggerDirectDownload).toHaveBeenCalledTimes(1);
});
it('detects iPadOS 13+ (reports as MacIntel + touch) as iOS', async () => {
const share = vi.fn().mockResolvedValue(undefined);
const canShare = vi.fn().mockReturnValue(true);
installNavigator({
userAgent: IPADOS_UA,
platform: 'MacIntel',
maxTouchPoints: 5,
share,
canShare,
});
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
expect(share).toHaveBeenCalledTimes(1);
expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled();
expect(galleryService.triggerDirectDownload).not.toHaveBeenCalled();
});
it('does NOT treat a regular Mac (MacIntel + no touch) as iOS', async () => {
const share = vi.fn().mockResolvedValue(undefined);
const canShare = vi.fn().mockReturnValue(true);
installNavigator({
userAgent: DESKTOP_UA,
platform: 'MacIntel',
maxTouchPoints: 0,
share,
canShare,
});
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
expect(share).not.toHaveBeenCalled();
expect(galleryService.triggerDirectDownload).toHaveBeenCalledTimes(1);
});
it('does not fall back to download when the user dismisses the iOS share sheet (AbortError)', async () => {
// AbortError signals a deliberate user dismissal; falling back to a
// download would surprise them with a file landing in Downloads
// anyway, defeating the dismissal.
const abortErr = Object.assign(new Error('user cancelled'), { name: 'AbortError' });
const share = vi.fn().mockRejectedValue(abortErr);
const canShare = vi.fn().mockReturnValue(true);
installNavigator({ userAgent: IOS_UA, share, canShare });
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
expect(share).toHaveBeenCalledTimes(1);
expect(galleryService.triggerBrowserDownload).not.toHaveBeenCalled();
});
it('does fall back to download when navigator.share() rejects with a non-Abort error', async () => {
const otherErr = Object.assign(new Error('not allowed'), { name: 'NotAllowedError' });
const share = vi.fn().mockRejectedValue(otherErr);
const canShare = vi.fn().mockReturnValue(true);
installNavigator({ userAgent: IOS_UA, share, canShare });
await galleryService.savePhotoToDevice('slug', 1, 'fallback.jpg');
expect(galleryService.triggerBrowserDownload).toHaveBeenCalledTimes(1);
});
});
+208 -28
View File
@@ -1,6 +1,34 @@
import { api } from '../config/api';
import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types';
import { normalizeRequirePassword } from '../utils/accessControl';
import { parseContentDispositionFilename } from '../utils/contentDisposition';
// iOS is the only platform whose system share sheet exposes a
// first-party "Save Image" / "Save to Photos" action for files
// shared via navigator.share(). On Android the share sheet only
// lists installed apps that registered an image/* intent (WhatsApp,
// Telegram, etc.) — there is no built-in save-to-gallery action,
// so the share path produces a useless app-picker for users who
// just wanted to save the photo (#554). UA-sniff is the only signal
// available because feature detection (canShare) is true on both.
//
// The MacIntel + maxTouchPoints clause covers iPadOS 13+ which
// identifies as Mac in navigator.userAgent but supports the same
// share-to-Photos flow as iOS Safari.
function isIOS(): boolean {
if (typeof navigator === 'undefined') return false;
const ua = navigator.userAgent || '';
if (/iPad|iPhone|iPod/.test(ua)) return true;
return navigator.platform === 'MacIntel' && (navigator.maxTouchPoints || 0) > 1;
}
// Hard cap on the multi-file Web Share path (#557). iOS Safari's share
// sheet starts to choke and silently fail beyond ~2530 files in
// practice; equally important, every File materialises as an in-memory
// Blob before share() is invoked, so a 500-photo @ 10 MB selection
// would buffer 5 GB on the device. Above this cap we fall through to
// the existing server-side zip flow.
const MAX_WEB_SHARE_FILES = 25;
export const galleryService = {
// Verify share token
@@ -47,40 +75,129 @@ export const galleryService = {
};
},
// Download single photo
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
// Save single photo. iOS routes through the Web Share API so the
// share sheet's "Save Image" action lands the file in Photos.
// Everywhere else (Android, desktop) navigates a hidden anchor
// straight at the download URL — the browser's native download UI
// shows up immediately and its progress lives in the notification
// shade. Buffering the blob through fetch first (the original
// path) added ~5s of dead air on cellular before any visible
// feedback, prompting users to re-click and produce duplicate
// downloads (#554 follow-up). Direct navigation eliminates the
// latency outright rather than masking it with a spinner.
async savePhotoToDevice(slug: string, photoId: number, filename: string): Promise<void> {
if (!isIOS()) {
this.triggerDirectDownload(
api.getUri({ url: `/gallery/${slug}/download/${photoId}` }),
filename,
);
return;
}
const fetched = await this.fetchPhotoBlob(slug, photoId);
const resolvedFilename = fetched.serverFilename || filename;
// canShare() returns false on browsers without Web Share file
// support. Probe with a representative File so the negotiation
// is accurate — `canShare({ files: [] })` returns true on some
// browsers that don't actually accept files at share() time.
const file = new File([fetched.blob], resolvedFilename, {
type: fetched.blob.type || 'image/jpeg',
});
const canShareFile =
typeof navigator !== 'undefined' &&
typeof navigator.canShare === 'function' &&
navigator.canShare({ files: [file] });
if (canShareFile) {
try {
await navigator.share({ files: [file], title: resolvedFilename });
return;
} catch (err) {
// AbortError = user dismissed the share sheet. Don't fall back —
// they made a choice. Any other failure (NotAllowedError,
// DataError, etc.) is unexpected; surface a download instead so
// the user still gets the file.
if ((err as DOMException)?.name === 'AbortError') return;
}
}
this.triggerBrowserDownload(fetched.blob, resolvedFilename);
},
// Fetch the photo as a Blob + the server-suggested filename, falling
// back to the view endpoint when the original isn't available. Shared
// between the regular download flow and the Web Share path (#531).
// The server's Content-Disposition is the source of truth for the
// filename (#493 — "use original camera filename" toggle reaches disk
// through this header).
async fetchPhotoBlob(
slug: string,
photoId: number,
): Promise<{ blob: Blob; serverFilename: string | null }> {
const readResponse = (response: { data: Blob; headers: Record<string, string> }) => {
const headerName =
response.headers['content-disposition'] || response.headers['Content-Disposition'];
return {
blob: response.data,
serverFilename: parseContentDispositionFilename(headerName),
};
};
try {
const response = await api.get(`/gallery/${slug}/download/${photoId}`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (err) {
// Fallback: use the view endpoint if direct download fails (e.g., missing original)
try {
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
responseType: 'blob',
});
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (fallbackErr) {
throw fallbackErr;
}
return readResponse(response);
} catch {
// Fallback: view endpoint when /download isn't available (e.g.
// the original is missing and only a derivative remains). The
// view endpoint doesn't emit a download-oriented Content-Disposition,
// so serverFilename will be null and the caller's name wins.
const response = await api.get(`/gallery/${slug}/photo/${photoId}`, {
responseType: 'blob',
});
return readResponse(response);
}
},
// Trigger a regular browser download via a transient <a download>
// anchor. Extracted from downloadPhoto so the share-fallback path
// can reuse it without re-fetching the blob.
triggerBrowserDownload(blob: Blob, filename: string): void {
const url = window.URL.createObjectURL(new Blob([blob]));
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
// Trigger a browser-native download by navigating a hidden anchor at
// the URL directly. The browser fetches the response itself (showing
// its own progress UI), so unlike triggerBrowserDownload the JS layer
// never materialises the bytes. `filename` is a hint; the server's
// Content-Disposition wins per spec, which is what carries the #493
// original-camera-filename setting through to disk.
triggerDirectDownload(href: string, filename: string): void {
const link = document.createElement('a');
link.href = href;
link.setAttribute('download', filename);
document.body.appendChild(link);
link.click();
link.remove();
},
// Download single photo — kept as the canonical name for the existing
// grid + lightbox-action callers that haven't been migrated to the
// share-aware savePhotoToDevice path yet.
async downloadPhoto(slug: string, photoId: number, filename: string): Promise<void> {
const fetched = await this.fetchPhotoBlob(slug, photoId);
this.triggerBrowserDownload(fetched.blob, fetched.serverFilename || filename);
},
// Download all photos as ZIP
// When a pre-generated zip is available, use native browser download (Content-Length → progress bar).
// Otherwise fall back to blob download.
@@ -112,8 +229,24 @@ export const galleryService = {
window.URL.revokeObjectURL(url);
},
// Download selected photos as ZIP
// Download selected photos. On iOS with a small selection, route
// through Web Share so the files land directly in Photos via the
// share sheet's "Save N Images" action (#557, extending #531 to the
// multi-photo case). Above the cap, or anywhere else, fall through
// to the existing server-side zip flow.
async downloadSelectedPhotos(slug: string, photoIds: number[]): Promise<void> {
if (
isIOS() &&
photoIds.length > 0 &&
photoIds.length <= MAX_WEB_SHARE_FILES
) {
const status = await this.trySaveMultipleToDevice(slug, photoIds);
// 'shared' = share() resolved; 'dismissed' = user closed the
// share sheet — both terminate the flow without touching the
// zip path. Only 'fallback' continues below.
if (status !== 'fallback') return;
}
const response = await api.post(`/gallery/${slug}/download-selected`, { photo_ids: photoIds }, {
responseType: 'blob',
});
@@ -128,6 +261,53 @@ export const galleryService = {
window.URL.revokeObjectURL(url);
},
// iOS-only Web Share path for a selection of photos.
//
// Returns:
// 'shared' — navigator.share resolved; files are now in the OS share sheet
// 'dismissed' — user cancelled the share sheet (AbortError); do NOT fall back
// 'fallback' — capability missing or unexpected failure; caller should
// use the server-side zip path instead
//
// Callers must gate by isIOS() + count <= MAX_WEB_SHARE_FILES before
// invoking this; the method does not re-check those conditions.
async trySaveMultipleToDevice(
slug: string,
photoIds: number[],
): Promise<'shared' | 'dismissed' | 'fallback'> {
let fetched: Array<{ blob: Blob; serverFilename: string | null }>;
try {
// Parallel fetch — modern browsers cap at ~6 connections per origin
// on HTTP/1.1, unlimited on HTTP/2, so 25 concurrent requests is
// safe without an explicit semaphore. A single failed fetch
// collapses the whole selection back to the zip path; partial
// shares would leave the user wondering which photos were saved.
fetched = await Promise.all(photoIds.map((id) => this.fetchPhotoBlob(slug, id)));
} catch {
return 'fallback';
}
const files = fetched.map((entry, idx) => {
const name = entry.serverFilename || `photo-${photoIds[idx]}.jpg`;
return new File([entry.blob], name, { type: entry.blob.type || 'image/jpeg' });
});
const canShareFiles =
typeof navigator !== 'undefined' &&
typeof navigator.canShare === 'function' &&
navigator.canShare({ files });
if (!canShareFiles) return 'fallback';
try {
await navigator.share({ files });
return 'shared';
} catch (err) {
if ((err as DOMException)?.name === 'AbortError') return 'dismissed';
return 'fallback';
}
},
// Toggle photo visibility (client-only)
async togglePhotoVisibility(slug: string, photoId: number, visibility: 'visible' | 'hidden'): Promise<void> {
await api.patch(`/gallery/${slug}/photos/${photoId}/visibility`, { visibility });
+10 -2
View File
@@ -1,4 +1,5 @@
import { api } from '../config/api';
import { parseContentDispositionFilename } from '../utils/contentDisposition';
export interface AdminPhoto {
id: number;
@@ -102,11 +103,18 @@ class PhotosService {
const response = await api.get(`/admin/events/${eventId}/photos/${photoId}/download`, {
responseType: 'blob'
});
// Read the filename from the server's Content-Disposition so the
// #493 original-filename toggle reaches disk for admin downloads
// too (see contentDisposition.ts).
const headerName =
response.headers['content-disposition'] || response.headers['Content-Disposition'];
const serverFilename = parseContentDispositionFilename(headerName);
const url = window.URL.createObjectURL(new Blob([response.data]));
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.download = serverFilename || filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
@@ -39,6 +39,9 @@ export interface PublicSettings {
branding_youtube_url?: string;
branding_promo_markdown?: string;
branding_promo_position?: 'above_footer' | 'below_footer';
// Per-install promo banner alignment (#482). Defaults to 'center'
// so the banner aligns with the gallery footer's centering.
branding_promo_alignment?: 'left' | 'center' | 'right';
theme_config: any;
default_language: string;
enable_analytics: boolean;
@@ -59,6 +62,7 @@ export interface PublicSettings {
event_require_event_date?: boolean;
event_require_expiration?: boolean;
event_default_require_password?: boolean;
event_default_feedback_enabled?: boolean;
gallery_show_filter_bar?: boolean;
event_phone_field_enabled?: boolean;
// SEO meta tags (consumed by RobotsMetaTags)
+11 -1
View File
@@ -42,6 +42,9 @@ export interface BrandingSettings {
youtube_url?: string;
promo_markdown?: string;
promo_position?: 'above_footer' | 'below_footer';
// Per-install promo banner alignment (#482). Defaults to center
// so the banner aligns with the gallery footer.
promo_alignment?: 'left' | 'center' | 'right';
}
export interface ThemeSettings {
@@ -149,6 +152,10 @@ export interface PublicSiteBranding {
accent: string;
background: string;
text: string;
surface?: string;
elevated?: string;
border?: string;
mutedText?: string;
};
}
@@ -336,7 +343,10 @@ export const settingsService = {
twitter_url: rawSettings.branding_twitter_url || '',
youtube_url: rawSettings.branding_youtube_url || '',
promo_markdown: rawSettings.branding_promo_markdown || '',
promo_position: rawSettings.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer'
promo_position: rawSettings.branding_promo_position === 'below_footer' ? 'below_footer' : 'above_footer',
promo_alignment: ['left', 'center', 'right'].includes(rawSettings.branding_promo_alignment)
? rawSettings.branding_promo_alignment
: 'center'
};
},

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