Compare commits

...

104 Commits

Author SHA1 Message Date
Paul Nothaft 59b56ed3d7 Merge pull request #309 from the-luap/release-please--branches--beta
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(beta): release 3.28.2-beta.0
2026-04-12 21:01:11 +02:00
github-actions[bot] 0a5b07de5d chore(beta): release 3.28.2-beta.0 2026-04-12 18:58:40 +00:00
Paul Nothaft b05c36ac81 Merge pull request #308 from the-luap/fix/welcome-message-and-guest-thumbnails
fix: display welcome message in gallery and fix guest thumbnail URLs (#306, #307)
2026-04-12 20:58:24 +02:00
Paul Nothaft 9323befdd9 fix: display welcome message in gallery and fix guest thumbnail URLs (#306, #307)
- Render welcome_message in gallery view for all non-fullpage layouts
  (grid, masonry, carousel, timeline, mosaic) as a centered banner
- Add /api prefix to thumbnail/photo URLs in adminGuests.js and
  adminPhotos.js so they route correctly through Nginx proxy
2026-04-12 20:58:02 +02:00
Paul Nothaft 61142c0d0e Merge pull request #305 from the-luap/release-please--branches--beta
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(beta): release 3.28.1-beta.0
2026-04-12 10:06:43 +02:00
github-actions[bot] 623ab72916 chore(beta): release 3.28.1-beta.0 2026-04-12 08:06:08 +00:00
Paul Nothaft 3716ff5085 Merge pull request #304 from the-luap/fix/gallery-sort-direction-and-feedback-visibility
fix: apply sort direction in gallery and respect show_feedback_to_guests (#302, #303)
2026-04-12 10:05:52 +02:00
Paul Nothaft dffe057772 fix: apply sort direction in gallery view and respect show_feedback_to_guests (#302, #303)
- Gallery now respects the configured sort direction (asc/desc) from
  default_photo_sort setting instead of using hard-coded directions
- Photos endpoint zeroes out feedback fields (like_count, favorite_count,
  average_rating, comment_count, has_feedback) when show_feedback_to_guests
  is disabled, while still showing data to admin/client users
2026-04-12 10:05:24 +02:00
Paul Nothaft 3319a304ce Merge pull request #301 from the-luap/release-please--branches--beta
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(beta): release 3.28.0-beta.0
2026-04-11 23:24:59 +02:00
github-actions[bot] c303dd51e8 chore(beta): release 3.28.0-beta.0 2026-04-11 21:24:41 +00:00
Paul Nothaft b1dfbe4c2f Merge pull request #300 from the-luap/feat/cookie-secure-auto
feat: add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments (#298)
2026-04-11 23:24:24 +02:00
Paul Nothaft 54badefc51 Merge pull request #299 from the-luap/fix/guest-masonry-lightbox
fix: guest feedback flow bugs in Masonry grid and PhotoLightbox (#292)
2026-04-11 23:24:02 +02:00
Paul Nothaft 15a8ab41fd feat: add COOKIE_SECURE=auto for mixed HTTPS/HTTP deployments (#298)
Adds a third value for the COOKIE_SECURE environment variable that
decides the cookie Secure flag per-request based on req.secure. This
unblocks a common self-hosted setup where the same PicPeak deployment
is reachable over both HTTPS (via reverse proxy) and plain HTTP (e.g.
LAN access at http://192.168.x.x:3001).

Behavior

  unset  - legacy default: follows NODE_ENV (production=true, dev=false)
  true   - always set Secure (unchanged)
  false  - never set Secure (unchanged)
  auto   - NEW: use req.secure per request. In practice this means
           Secure on HTTPS requests (when X-Forwarded-Proto: https
           reaches Express via a trusted proxy) and no Secure flag
           on plain HTTP requests.

The existing trust proxy config (`app.set('trust proxy',
'loopback, linklocal, uniquelocal')` in server.js) means
X-Forwarded-Proto is honored when forwarded from local/private-network
proxies, which covers Docker network setups and most self-hosted
deployments behind NPM, Traefik, or Caddy.

auto is strictly opt-in. The default behavior is unchanged, so existing
users see no difference. A follow-up release can consider promoting
auto to the default after real-world feedback.

Also fixed (latent bug, benefits everyone)

Cookie clear operations (clearAdminAuthCookie, clearGalleryAuthCookies)
previously wrote the same `secure` attribute as the set path. When a
cookie was set with Secure=true over HTTPS and the clear request came
over HTTP (or vice versa under auto mode), some browsers would reject
the Set-Cookie delete header, leaving the cookie in place. Browsers
match cookies by (name, domain, path) for deletion and don't care about
Secure, so the new buildClearCookieOptions() helper simply omits the
secure attribute.

Implementation

- secureCookie string is replaced by secureCookieMode which can hold
  true, false, or 'auto'.
- New resolveSecureFlag(res) returns the boolean for a specific
  response, delegating to res.req.secure when in auto mode.
- buildCookieBaseOptions and buildCookieOptionsWithExpiry now take res
  and pass it through.
- New buildClearCookieOptions() deliberately omits `secure`.
- setAdminAuthCookie / setGalleryAuthCookies / clearAdminAuthCookie /
  clearGalleryAuthCookies all updated to thread res where needed.
  Public signatures unchanged — every caller already has res in scope.

Testing

Verified against a real Express instance inside the backend container
with trust proxy configured, covering:

  - (unset) + NODE_ENV=production -> secure: true (legacy)
  - (unset) + NODE_ENV=development -> secure: false (legacy)
  - COOKIE_SECURE=true + req.secure=false -> secure: true (literal wins)
  - COOKIE_SECURE=false + req.secure=true -> secure: false (literal wins)
  - COOKIE_SECURE=auto + X-Forwarded-Proto: https -> secure: true
  - COOKIE_SECURE=auto + plain HTTP -> secure: false
  - clearCookie always omits the secure attribute

Documentation

Added a COOKIE_SECURE block to both .env.example files (root for
docker-compose, backend/.env.example for native install) explaining the
four values, when to use auto, and the two requirements (proxy must
forward X-Forwarded-Proto, proxy IP must be in the trust list). Also
documented COOKIE_SAMESITE and COOKIE_DOMAIN alongside, which were
previously undocumented.
2026-04-11 22:41:15 +02:00
Paul Nothaft 77f07e9329 fix: guest feedback flow bugs in Masonry grid and PhotoLightbox (#292)
Fixes two bugs reported on #292 after 3.27.0-beta.0 shipped:

1. Masonry grid showed no visual feedback after liking a photo.
   MasonryGalleryLayout's Like button had no liked-state plumbing —
   the Heart icon was a static <Heart> regardless of whether the user
   had liked the photo.

2. PhotoLightbox (fullscreen view) silently failed to like photos in
   guest identity mode. submitLike() and submitRating() never called
   ensureIdentity() before firing the API request, so the first
   interaction from a fresh session hit a 401 from the server instead
   of opening the name prompt.

Root causes:

1. MasonryGalleryLayout was missing the 'liked' state pattern that
   GridGalleryLayout already uses (likedPhotoIds Set in the parent,
   passed down as a `liked` prop, updated via onLikeSuccess callback).
   The bug was invisible in simple mode (no personal state) but
   surfaced immediately in guest mode where each guest expects to see
   confirmation of their own action.

2. PhotoLightbox's submit handlers were written before the guest
   identity context existed and only checked the legacy
   require_name_email flag. They were never updated when guest mode
   landed.

Also fixed: z-index conflict where the GuestNamePromptModal (z-50)
was sitting at the same level as PhotoLightbox (z-50), so when the
prompt opened over the lightbox, the fullscreen image intercepted
pointer events and the modal's Continue button was unclickable.
Bumped both guest modals to z-[60].

Changes:

- MasonryGalleryLayout.tsx
  - MasonryPhotoProps gains `liked?: boolean` + `onLikeSuccess?: () => void`.
  - Like button: red bg + filled white Heart icon when liked; aria-label
    toggles between "Like photo"/"Unlike photo"; aria-pressed mirrors state.
  - onClick wires onLikeSuccess() for optimistic UI in both guest-mode
    and simple-mode branches plus the FeedbackIdentityModal onSubmit path.
  - Parent layout holds `likedPhotoIds: Set<number>` and passes it to
    each MasonryPhoto (matches the GridGalleryLayout pattern).

- PhotoLightbox.tsx
  - Consumes useGuestIdentityOptional(); new `isGuestMode` flag.
  - submitLike() and submitRating() get a guest-mode branch that calls
    ensureIdentity() first and submits without body guest_name/email
    (server reads from the verified token).
  - Optimistic UI updates happen after successful submit in guest mode.

- GuestNamePromptModal.tsx, GuestRecoveryModal.tsx
  - z-50 → z-[60] so they render above PhotoLightbox.

Verified end-to-end against local Docker with Playwright MCP on event
168 (Masonry Columns Test layout):

- Fresh session, click Like in Masonry grid → name prompt opens, register,
  feedback persists with guest_id, Heart button turns red with
  aria-pressed and "Unlike photo" label. Subsequent likes on other
  photos also show red state. DB confirms feedback rows.

- Fresh session, open photo in lightbox BEFORE registering → click Like,
  the name prompt correctly opens on top of the lightbox, register,
  feedback persists. Rate 4 stars → works, average 4.0 (1) displayed
  in lightbox, ★ badge appears on toggle-feedback button, grid cell
  shows "1 likes" + "Rating: 4.0" indicators after closing lightbox.

- Backend DB: gallery_guests row created, photo_feedback rows have
  correct guest_id, server reads name from verified token (body values
  ignored).

Out of scope (documented in audit, not reported by the user, no
regression from guest mode): Mosaic/Carousel/Timeline have partial
optimistic-UI issues unrelated to this report; they pre-date guest
mode and behave the same in simple mode. Leaving alone per scope
discipline.
2026-04-11 14:42:28 +02:00
Paul Nothaft 72c0c2d18e Merge pull request #297 from the-luap/release-please--branches--beta
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(beta): release 3.27.0-beta.0
2026-04-11 09:27:58 +02:00
github-actions[bot] 95d8bc4065 chore(beta): release 3.27.0-beta.0 2026-04-11 06:29:51 +00:00
Paul Nothaft 3856ba25bb Merge pull request #295 from the-luap/feat/guest-selections
feat: guest selections with per-person identity (#292)
2026-04-11 08:27:31 +02:00
Paul Nothaft 9e1ba4f851 Merge pull request #296 from the-luap/release-please--branches--beta
chore(beta): release 3.26.2-beta.0
2026-04-11 08:27:08 +02:00
github-actions[bot] b0efd32f7a chore(beta): release 3.26.2-beta.0 2026-04-11 06:26:39 +00:00
Paul Nothaft 9ed8a2b199 Merge pull request #294 from the-luap/fix/admin-photo-feedback-filters
fix: admin photo feedback filters have no effect (#293)
2026-04-11 08:26:19 +02:00
Paul Nothaft ad4e5a7506 feat: guest selections with per-person identity (#292)
Introduces a new "Per-guest selections" identity mode for event
feedback, letting each visitor register under their own name so their
likes/favorites/comments/ratings are tracked independently. Includes
admin insights (list, per-guest detail, aggregate view, export) and
advanced identity features (forget-me, email recovery, invite tokens,
merge).

New event-level setting
- event_feedback_settings.identity_mode = 'simple' | 'guest' (default
  'simple' → zero behavior change for existing events).
- Admin UI radio under Feedback Settings to toggle per event.

Root cause of the previous "all guests share state" bug
- generateGuestIdentifier() was sha256(ip + userAgent), so every visitor
  on the same WiFi + similar device collided into one identity.
- Now: when a verified guest JWT is present (x-guest-token header),
  req.guest.identifier takes precedence — per-person rate limits and
  per-person deduplication.

Phase 1 — identity layer
- Migration 078: new gallery_guests, guest_invites, guest_verification_
  codes tables; identity_mode column + check constraint; nullable
  guest_id FK on photo_feedback.
- New guest JWT type scoped to (eventId, guestId).
- New middleware guestAuth.resolveGuest (non-blocking) + requireGuest.
- POST /gallery/:slug/guest, GET /guest/me, DELETE /guest/me.
- Gallery feedback route enforces guest identity in guest mode and
  reads name/email from the verified token (never from the body).
- Frontend GuestIdentityContext + GuestNamePromptModal; axios
  interceptor injects x-guest-token on gallery API calls.
- Feedback-only blocking: gallery opens freely, prompt only on first
  interactive feedback action.
- Admin "Guests" tab (conditional on identity_mode='guest') with the
  AdminGuestsList component.

Phase 2 — admin insights
- GET /admin/events/:eventId/guests list + aggregated counts.
- GET /admin/events/:eventId/guests/:guestId detail with per-type
  groupings; AdminGuestDetail modal with thumbnail grid + tabs.
- GET /admin/events/:eventId/guests/aggregate sorted by distinct guest
  pick count; GuestSelectionsAggregate component.
- Per-guest export (txt/csv/json) and bulk export-all ZIP.

Phase 3 — polish
- 3.1 Self-service forget-me link in gallery footer.
- 3.2 Email-based identity recovery: POST /guest/recover sends a
  6-digit code via the existing emailProcessor, POST /guest/verify
  exchanges it for a token (rate-limited, enumeration-safe).
- 3.3 Admin invite tokens: pre-mint identities, share URLs with
  ?invite=, single-use redemption stripping the param from history.
- 3.4 Admin merge endpoint reassigns feedback + soft-deletes sources.

Shared helper
- useGalleryFeedbackAction hook wraps the identity-check logic for
  inline like buttons across Masonry/Grid/Justified/Mosaic/Carousel/
  Timeline/Premium layouts.

Backwards compatibility
- Existing events default to 'simple' after migration; behavior
  unchanged.
- Legacy photo_feedback rows keep guest_id NULL; admin shows them in
  the generic feedback moderation view as before.
- feedback_count denormalized stat now uses COALESCE(guest_id,
  guest_identifier) so per-guest counts are accurate without touching
  legacy rows.

Verified end-to-end against local Docker
- Migration clean on existing data.
- Simple mode unchanged (no prompt, legacy flow).
- Guest mode: Alice registers on click, tokens persist in
  sessionStorage, feedback rows carry guest_id.
- Carol via invite link auto-redeems, sees Alice's "1 likes" badge.
- Admin Guests tab shows both with correct counts; detail modal
  displays thumbnail grid with badges; aggregate view sorts by picker
  count (photo 227 = 2, others = 1); CSV/JSON export matches DB.
- Merge Carol into Alice: feedback reassigned, Carol soft-deleted,
  Alice count = 4.
2026-04-11 07:48:23 +02:00
Paul Nothaft d4b4dc628f fix: wire admin photo feedback filters into grid query (#293)
The Has Likes / Has Favorites / Has Comments checkboxes in the admin
Event > Photos tab updated local state but never affected the visible
photo grid, because the feedbackFilters state was only wired to the
export menu and the backend /admin/photos/:eventId/photos endpoint had
no support for these params.

Fixes:
- backend/src/routes/adminPhotos.js: extend GET /:eventId/photos to
  accept has_likes, has_favorites, has_comments, min_rating, and logic
  (AND/OR) query params and apply them via where-clause groups using
  the existing denormalized like_count/favorite_count/comment_count/
  average_rating columns.
- frontend/src/services/photos.service.ts: add hasLikes, hasFavorites,
  hasComments, minRating, logic to the PhotoFilters interface and
  append them as query params in getEventPhotos.
- frontend/src/pages/admin/EventDetailsPage.tsx: merge feedbackFilters
  into combinedPhotoFilters (via useMemo) and key the admin-event-photos
  query on it, so toggling any checkbox refetches with the new params.

Verified end-to-end against local Docker: seeded event with a known
feedback distribution and confirmed
- Has Likes → 4 photos
- Has Favorites → 3 photos
- Likes AND Favorites → 1 photo
- Likes OR Favorites → 6 photos
- Has Comments → 2 photos
- network requests carry the exact query params
2026-04-11 07:46:32 +02:00
Paul Nothaft fe46e4268d Merge pull request #288 from the-luap/release-please--branches--beta
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(beta): release 3.26.1-beta.0
2026-04-09 16:40:53 +02:00
github-actions[bot] 1f3b9c6712 chore(beta): release 3.26.1-beta.0 2026-04-09 14:27:12 +00:00
Paul Nothaft 5295516b67 Merge pull request #290 from the-luap/docs/fix-filesystem-gallery-docs
docs: clarify file system photo import requires existing event (#269)
2026-04-09 16:26:51 +02:00
Paul Nothaft ee0baafc59 docs: clarify file system photo import requires existing event (#269)
The "Method 2: File System" section in SIMPLE_SETUP.md implied you
could create a gallery by just copying files to the storage directory.
In reality, the event must exist in the database first — the file
watcher only adds photos to existing events.

Rewritten to clarify the prerequisite and explain how the file watcher
works (2s stability delay, supported formats, auto-thumbnailing).
2026-04-09 16:26:39 +02:00
Paul Nothaft c63bc47089 Merge pull request #289 from the-luap/fix/password-change-regular-modal
fix: apply password change fix to regular modal + longer toast delay (#263)
2026-04-09 16:06:13 +02:00
Paul Nothaft 147dc28440 fix: apply password change redirect fix to regular modal too (#263)
The redirect loop fix only covered MandatoryPasswordChangeModal.
The regular PasswordChangeModal (profile settings) had the same
issue — onSuccess updated React state but didn't handle the new
JWT cookie, causing the same redirect loop.

Also increase redirect delay from 500ms to 2000ms in both modals
so the success toast is visible before the page reloads.
2026-04-09 16:05:53 +02:00
Paul Nothaft c031b1e863 Merge pull request #287 from the-luap/fix/password-change-iat-timing
fix: resolve JWT iat timing issue in password change (#263)
2026-04-09 16:00:25 +02:00
Paul Nothaft b1d16670d5 fix: set JWT iat after password_changed_at to prevent token rejection (#263)
The new token issued after password change had iat (integer seconds)
that was <= password_changed_at (millisecond precision), causing the
auth middleware's "iat < passwordChangedTime" check to reject it
immediately. Set iat explicitly to 1 second after password_changed_at.

E2E tested: login → mandatory password change → dashboard loads
successfully with no redirect loop and no 401 errors.
2026-04-09 16:00:03 +02:00
Paul Nothaft ba1f010166 Merge pull request #285 from the-luap/release-please--branches--beta
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(beta): release 3.26.0-beta.0
2026-04-09 15:40:18 +02:00
github-actions[bot] ad64005a80 chore(beta): release 3.26.0-beta.0 2026-04-09 13:11:41 +00:00
Paul Nothaft 8805fa53e6 Merge pull request #286 from the-luap/feat/photo-sort-by-capture-date
feat: sort photos by capture date with configurable default sort (#283)
2026-04-09 15:11:19 +02:00
Paul Nothaft 633d4a0f30 feat: sort photos by capture date with configurable default sort (#283)
Add per-event default photo sort setting with 6 options:
- Upload Date (Newest/Oldest First)
- Date Taken (Newest/Oldest First) — uses EXIF captured_at
- Filename (A-Z / Z-A)

Backend:
- Migration 077 adds default_photo_sort column to events table
- Event create/update handlers accept and validate the setting
- Gallery info endpoint returns default_photo_sort for frontend

Frontend:
- "Date Taken" added to gallery sort dropdown (alongside Date, Name,
  Size, Rating)
- Gallery initializes with event's default sort instead of hardcoded
  "date"
- "Default Photo Sort" dropdown in event create and edit forms
- Photos without EXIF dates fall back to upload date

i18n: All 5 locales (EN, DE, NL, PT, RU) updated with sort labels.

Closes #283
2026-04-09 15:10:51 +02:00
Paul Nothaft b23c51b386 Merge pull request #284 from the-luap/fix/password-change-loop-and-filewatcher
fix: resolve password change redirect loop (#263) and file watcher crash (#269)
2026-04-09 13:54:54 +02:00
Paul Nothaft 835bdf5abb fix: resolve password change redirect loop and file watcher crash
#263: The mandatory password change modal updated React state before
the browser stored the new JWT cookie, causing a race condition where
the auth context checked the session with the old (invalidated) token.
Replace the state update with a full page redirect to /admin/dashboard
after a brief delay, ensuring the new cookie is applied cleanly.

#269: The file watcher service imported isVideoMimeType from
fileSecurityUtils where it doesn't exist. The function is exported
from videoProcessor. Fix the import path.

Closes #269
2026-04-09 13:54:23 +02:00
Paul Nothaft a1b63de251 Merge pull request #279 from the-luap/release-please--branches--beta
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(beta): release 3.25.0-beta.0
2026-04-08 12:03:29 +02:00
github-actions[bot] 97b1ae5b03 chore(beta): release 3.25.0-beta.0 2026-04-08 09:51:27 +00:00
Paul Nothaft dc98206737 Merge pull request #278 from the-luap/feat/draft-mode-branding-improvements
feat: draft mode, admin branding, and workflow improvements
2026-04-08 11:51:07 +02:00
Paul Nothaft 40332a71db feat: draft mode, admin branding, and workflow improvements
Draft Mode:
- Events are created as drafts by default — no email sent until published
- Add "Publish & Notify Client" button with confirmation dialog
- Draft banner with yellow styling on event details page
- Draft filter tab in events list
- Gallery middleware blocks public access to draft events
- Migration 076 adds is_draft column to events table

Admin Draft Preview:
- Admins can preview draft galleries via JWT preview token (?preview=)
- "View Gallery" link on drafts auto-appends preview token

Admin & Login Page Branding:
- Admin header uses configured company logo/name from branding settings
- Login page shows configured logo instead of hardcoded PicPeak
- Respects logo_display_mode (logo_only, text_only, logo_and_text)

OG Tag Branding:
- DynamicFavicon component updates OG meta tags and page title from
  branding settings

Editable Client Email:
- Customer email is now editable after event creation in edit mode

Branding Inheritance:
- New events inherit hero logo settings (visibility, size, position)
  from global branding configuration

Share Link Full Domain URL:
- New getFrontendBaseUrl() utility with DB fallback to general_site_url
- Used in email processor and share link service
2026-04-08 11:42:38 +02:00
Paul Nothaft 125cd0d003 Merge pull request #274 from the-luap/security/fix-dep-vulnerabilities
security: fix 20 dependency vulnerabilities
2026-04-08 09:04:19 +02:00
Paul Nothaft 83868ffe2f security: fix 20 dependency vulnerabilities (11 error, 7 warning, 2 note)
Update direct dependencies and overrides to address GitHub code scanning alerts:

- handlebars 4.7.8 -> 4.7.9 (5 CVEs: RCE, DoS, XSS, code execution)
- nodemailer 7.0.12 -> 7.0.13 (SMTP command injection)
- tar 7.5.11 -> 7.5.13 override (symlink/hardlink path traversal)
- fast-xml-parser >=5.3.8 -> >=5.5.10 override (entity expansion bypass)
- brace-expansion >=5.0.0 -> >=5.0.5 override (DoS via zero step)
- path-to-regexp 0.1.12 -> 0.1.13 override (ReDoS via malformed URL params)
- lodash 4.17.23 -> >=4.18.1 override (prototype pollution, code execution)

The picomatch CVEs are in npm's own node_modules inside the Docker image
and do not affect application code.
2026-04-08 09:04:06 +02:00
Paul Nothaft 9ddd50f7e4 Merge pull request #266 from the-luap/security/pin-axios-version
security: pin axios to 1.14.0 — supply chain attack prevention
2026-04-05 18:40:47 +02:00
Paul Nothaft bec36fc99f security: pin axios to 1.14.0 to prevent supply chain attack
Axios versions 1.14.1 and 0.30.4 were compromised on March 31, 2026
with a RAT dropper (plain-crypto-js) attributed to North Korean threat
actor UNC1069/Sapphire Sleet. The malicious versions have been removed
from npm but our ^1.12.2 range could have pulled 1.14.1 on next install.

Pin to exact version 1.14.0 (latest safe release) in both frontend and
backend package.json and lock files to prevent any future resolution to
compromised versions.

References:
- https://github.com/axios/axios/issues/10604
- https://snyk.io/blog/axios-npm-package-compromised-supply-chain-attack-delivers-cross-platform/
2026-04-05 18:40:24 +02:00
Paul Nothaft ea50488e99 Merge pull request #265 from the-luap/release-please--branches--beta
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(beta): release 3.24.1-beta.0
2026-04-05 18:36:13 +02:00
github-actions[bot] 8614c2232c chore(beta): release 3.24.1-beta.0 2026-04-05 16:34:26 +00:00
Paul Nothaft 07fc5e6519 Merge pull request #264 from the-luap/fix/password-change-redirect-loop
fix: resolve redirect loop after mandatory password change (#263)
2026-04-05 18:34:07 +02:00
Paul Nothaft 3c8d344ddd fix: resolve redirect loop after mandatory password change (#263)
After changing password, the backend sets password_changed_at which
invalidates the old JWT token. But the frontend still holds the old
token in the HttpOnly cookie, so the next session check returns 401,
triggering an infinite redirect loop between /admin/login and
/admin/dashboard.

Fix: issue a new JWT token cookie after successful password change
so the session remains valid without requiring re-login.
2026-04-05 18:33:46 +02:00
Paul Nothaft edf8bd54af Merge pull request #262 from the-luap/release-please--branches--beta
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(beta): release 3.24.0-beta.0
2026-04-05 18:24:36 +02:00
github-actions[bot] 2b7c9b0138 chore(beta): release 3.24.0-beta.0 2026-04-04 21:33:29 +00:00
Paul Nothaft aef9b4ed7f Merge pull request #261 from the-luap/feat/beta-theme-thumbnail-warning
feat: warn about low thumbnail resolution with beta themes
2026-04-04 23:33:12 +02:00
Paul Nothaft ee3f6ae13b feat: warn about low thumbnail resolution when selecting beta themes
Beta themes (Gallery Premium, Gallery Story) display thumbnails at
400-800px, but the default thumbnail size is 300x300px, causing visible
pixelation. Show an amber warning banner with a link to Thumbnail
Settings when a beta layout is active and thumbnails are below 500px.

Warning appears both in the preset selector and the layout selector
sections of the theme customizer.
2026-04-04 23:32:49 +02:00
Paul Nothaft 5025a42bf7 Merge pull request #259 from the-luap/release-please--branches--beta
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(beta): release 3.23.0-beta.0
2026-04-04 22:28:44 +02:00
github-actions[bot] 0a7a89045b chore(beta): release 3.23.0-beta.0 2026-04-04 20:13:49 +00:00
Paul Nothaft ddefd3a95e Merge pull request #260 from the-luap/fix/backend-dockerfile-npm-version
fix: pin npm to v10 in backend Dockerfile
2026-04-04 22:13:32 +02:00
Paul Nothaft 978e4473b5 fix: pin npm upgrade to v10 in backend Dockerfile
npm@latest resolves to v11 which has a broken promise-retry dependency
on Node 22 Alpine, causing Docker builds to fail. Pin to npm@10 which
stays compatible with the Node 22 base image.
2026-04-04 22:13:11 +02:00
Paul Nothaft 8c5996e4ec Merge pull request #258 from the-luap/feat/email-template-translations
feat: multilingual email templates with translations table
2026-04-04 17:43:29 +02:00
Paul Nothaft f50d7c0c51 feat: multilingual email templates with translations table
Replace column-based email template languages (subject_en/subject_de) with
a normalized email_template_translations table where each language is a row.
This allows adding new languages without schema changes.

- Add migration 075 to create email_template_translations table, migrate
  existing EN/DE data, and seed NL/PT/RU for customer-facing templates
- Update processTemplate() to query translations table with fallback chain
  (requested lang -> en -> first available), with legacy column fallback
- Restructure admin email API to return/accept translations object format
- Update frontend EmailConfigPage with dynamic 5-language tabs, translation
  count badges, and copy-from-language feature for empty translations
- Add Dutch to default language dropdown in general settings
- Add Dutch to clientAccessI18n and password security messages in emails
- Expand email domain detection for NL/BE/BR/PT/RU domains
- Add email i18n keys (copyFrom, noTranslation, etc.) across all 5 locales
2026-04-04 17:43:01 +02:00
Paul Nothaft 4ce8dd297a Merge pull request #257 from the-luap/release-please--branches--beta
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(beta): release 3.22.0-beta.0
2026-03-26 08:04:59 +01:00
github-actions[bot] 85a4eb90fd chore(beta): release 3.22.0-beta.0 2026-03-25 21:36:31 +00:00
Paul Nothaft e32da68cbd Merge pull request #256 from the-luap/feat/add-dutch-locale
feat: add Dutch locale and fix missing translation keys
2026-03-25 22:36:15 +01:00
Paul Nothaft b54a80d251 feat: add Dutch (nl) locale and fix missing translation keys across all locales
Add complete Dutch translation (2054 keys) with Netherlands flag in the
language selector. Also synchronize all existing locales so every language
has the same set of keys: added 29 missing keys to EN/RU/PT and 95 missing
keys to DE (moderation, analytics, CSS templates, backup, events).
2026-03-25 22:35:57 +01:00
github-actions[bot] 2ac6c51fe5 chore(beta): release 3.21.1-beta.0 (#255)
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
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-22 12:43:55 +01:00
Paul Nothaft 23cd9cb680 fix: address Shannon security assessment findings (37 vulnerabilities) (#254)
Remediate 4 Critical, 18 High, 10 Medium, and 5 Low vulnerabilities
identified in the Shannon security assessment (2026-03-20).

Critical fixes:
- Command injection via rsync SSH key path (INJ-VULN-01)
- Self-escalation to super_admin role (AUTHZ-VULN-11)
- Invite super_admin backdoor (AUTHZ-VULN-12)
- Handlebars SSTI in email templates (INJ-VULN-05)

Authentication hardening:
- Rate limit on share-link login (AUTH-VULN-01)
- X-Forwarded-For spoofing bypass (AUTH-VULN-02)
- reCAPTCHA fails closed when misconfigured (AUTH-VULN-03)
- Token revocation on admin/gallery logout (AUTH-VULN-04/05)
- Cookie Secure flag defaults true in production (AUTH-VULN-06)
- Remove JWT from admin login response body (AUTH-VULN-07)
- Timing-safe gallery slug validation (AUTH-VULN-09)
- Account lockout fails closed on DB error (AUTH-VULN-12)
- Session endpoint checks token revocation

Path traversal & file access:
- checksums endpoint path containment (INJ-VULN-03)
- manifest validate path containment (INJ-VULN-04)

XSS prevention:
- Block SVG data URIs in CSS sanitizer (XSS-VULN-01)
- Email preview iframe sandbox (XSS-VULN-02)
- SSR branding HTML escaping (XSS-VULN-03)
- User-Agent sanitization in feedback (XSS-VULN-04)

Authorization (IDOR):
- Event ownership middleware for all admin routes
- Cross-admin user profile read restriction (AUTHZ-VULN-10)

SSRF & infrastructure:
- Private IP validation for SMTP, S3, rsync hosts
- Replace inline JWT with standard adminAuth middleware
- CSRF Content-Type enforcement on mutating API endpoints
- CSP headers in nginx location blocks

Token revocation fix:
- Remove overly broad orWhere clause that invalidated all future tokens
- Allow empty-body POST requests (logout) in CSRF middleware

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-22 12:40:01 +01:00
github-actions[bot] a63f1a8dd9 chore(beta): release 3.21.0-beta.0 (#253)
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
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-18 10:59:14 +01:00
Paul Nothaft 954a0118ba fix: wrap test email with standard email template (#252)
Use wrapEmailHtml() for the test email so it matches the look of all
other emails sent by the platform (logo, footer, etc.).

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-18 10:49:45 +01:00
Paul Nothaft ee46088985 feat: add per-gallery thumbnail scale setting (#172) (#251)
Add a thumbnailScale field (xs/sm/md/lg/xl) to gallery layout settings
that adjusts column counts for Grid, Masonry (columns mode), and Mosaic
layouts. Each scale maps to a column offset applied on top of the
layout's base columns, letting photographers control photo density.

- Add thumbnailScale to GalleryLayoutSettings type
- Apply scale offset in Grid, Masonry, and Mosaic layout components
- Add thumbnail scale dropdown to admin theme customizer
- Conditionally show dropdown only for applicable layouts
- Safelist dynamic grid-cols classes in Tailwind config
- Add i18n keys for EN, DE, PT, RU locales

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-18 10:44:07 +01:00
github-actions[bot] 3742d71535 chore(beta): release 3.20.1-beta.0 (#250)
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
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-17 17:18:56 +01:00
Paul Nothaft 486239aeb9 fix: address beta feedback - gallery layout fixes, Russian locale, email logo (#249)
- Add Russian (Русский) to admin settings language dropdown
- Fix Premium layout hero using thumbnail instead of hero_url
- Hide "Uncategorized" section header in Story layout for uncategorized photos
- Add PhotoLightbox to Story layout so photo clicks open full-screen view
- Use full-res images in StoryPhotoCard instead of thumbnails
- Defer public gallery auto-login text until settings/locale are loaded
- Add validation and debug logging for email logo URL construction

Co-authored-by: Paul Nothaft <paul@MacStudio-von-Paul.local>
2026-03-17 17:16:50 +01:00
Paul Nothaft 2c5ae6fbb9 Merge pull request #248 from the-luap/release-please--branches--beta
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(beta): release 3.20.0-beta.0
2026-03-17 13:14:43 +01:00
github-actions[bot] f9889a93fb chore(beta): release 3.20.0-beta.0 2026-03-17 12:05:29 +00:00
Paul Nothaft 4a93e4e8cb Merge pull request #247 from the-luap/feat/photo-visibility-client-access
feat: photo visibility control with client access (#172)
2026-03-17 13:05:12 +01:00
Paul Nothaft e1b6e43e52 feat: photo visibility control with client access (#172)
Add two-tier gallery access system allowing clients (e.g., wedding couples) to
review and hide photos before the gallery is shared with guests.

Backend:
- Migration 074: add visibility column to photos, client_access_enabled/
  client_password_hash/client_share_token to events
- Client login endpoint (POST /auth/gallery/:slug/client-login) with bcrypt PIN
- Gallery photo list filters hidden photos for guests, shows all for clients
- Visibility toggle endpoints (single + bulk) for client access level
- Admin event CRUD supports client access fields
- Email template includes client access link + PIN (EN/DE/RU/PT)

Frontend:
- ClientAccessPage: PIN entry form at /gallery/:slug/client-access
- GalleryView: client mode banner, visibility counter, toggle controls
- GridGalleryLayout: eye/eye-off overlay per photo for clients
- AdminPhotoGrid: visibility badge, bulk Hide/Show buttons
- EventDetailsPage: Client Access settings section (toggle, PIN, link)
- CreateEventPage: client access toggle + PIN in event creation form
- GalleryAuthContext: accessLevel/isClient/clientLogin support
- New complete pt-BR locale (pt.json) with all translations
- Client access i18n keys for EN, DE, RU, PT
2026-03-17 13:04:41 +01:00
Paul Nothaft 999c66dbbf Merge pull request #244 from the-luap/release-please--branches--beta
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(beta): release 3.19.2-beta.0
2026-03-16 22:37:45 +01:00
github-actions[bot] f5997892c4 chore(beta): release 3.19.2-beta.0 2026-03-16 21:35:09 +00:00
Paul Nothaft 7ca96315e2 Merge pull request #243 from the-luap/fix/security-session-invalidation
fix(security): token invalidation on password change, session timeout enforcement
2026-03-16 22:34:53 +01:00
Paul Nothaft f3622396e7 fix(security): invalidate tokens on password change, enforce session timeout, fix role update
- Set password_changed_at when changing password via adminAuth route so
  existing JWT tokens are rejected by the auth middleware check
- Enforce session timeout on first request with unseen tokens by checking
  token iat against configured timeout (prevents bypass after server restart)
- Convert camelCase roleId/isActive to snake_case role_id/is_active in
  frontend updateUser service (fixes silent role update failures)

Resolves GHSA-rqg3-47p5-vgwg
2026-03-16 22:34:32 +01:00
Paul Nothaft 56cf60c570 Merge pull request #242 from the-luap/release-please--branches--beta
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(beta): release 3.19.1-beta.0
2026-03-16 22:32:17 +01:00
github-actions[bot] 2618415aa1 chore(beta): release 3.19.1-beta.0 2026-03-16 21:23:14 +00:00
Paul Nothaft dfae2c2bc6 Merge pull request #241 from the-luap/fix/external-media-dimensions-and-email-colors
fix: external media dimensions, theme race condition, email color customization
2026-03-16 22:22:58 +01:00
Paul Nothaft bbeedd1888 fix: resolve external media dimensions, gallery theme race condition, and add email color customization
- Fix dimension repair for external media by using photoResolver instead of hardcoded paths
- Extract photo dimensions via Sharp during external media import
- Remove duplicate theme useEffect from GalleryView to prevent flash/revert race condition
- Pass event welcome_message to Story layout footer for per-event customization
- Add email_primary_color/email_secondary_color settings with admin UI color pickers
- Add i18n keys for email branding in all 4 locales (en, de, ru, pt)
2026-03-16 22:22:36 +01:00
Paul Nothaft 201965b4b1 Merge pull request #240 from the-luap/release-please--branches--beta
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(beta): release 3.19.0-beta.0
2026-03-16 20:25:20 +01:00
github-actions[bot] 1468c459ba chore(beta): release 3.19.0-beta.0 2026-03-16 16:24:01 +00:00
Paul Nothaft 088de43f09 Merge pull request #239 from the-luap/feat/photo-cap-and-portuguese-locale
feat: add photo cap per event and Portuguese locale
2026-03-16 17:23:37 +01:00
Paul Nothaft 1fa222e9c4 feat: add photo cap per event and Portuguese (pt-BR) locale
- Add photo_cap column to events table (migration 074) to limit photos per event
- Enforce photo cap in upload route, returning 400 when limit exceeded
- Pass photo_cap through all event CRUD routes and frontend forms
- Add complete Portuguese (pt-BR) translation (2300+ strings)
- Register pt locale in i18n config, language selector, date formatting
- Add photoCap/photoCapHelp translation keys to all locale files (en, de, ru, pt)
2026-03-16 17:22:54 +01:00
Paul Nothaft 6aceb40595 Merge pull request #238 from the-luap/release-please--branches--beta
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(beta): release 3.18.2-beta.0
2026-03-16 16:26:55 +01:00
github-actions[bot] 431a82eca1 chore(beta): release 3.18.2-beta.0 2026-03-16 15:26:11 +00:00
Paul Nothaft 85a07fcca7 Merge pull request #237 from the-luap/fix/security-dep-updates
fix: resolve code scanning security alerts (multer, tar, Node 22)
2026-03-16 16:25:52 +01:00
Paul Nothaft 1f524f2358 fix: update dependencies to resolve code scanning security alerts
- Upgrade multer to 2.1.1 (CVE-2026-3520, DoS via malformed requests)
- Update tar override to >=7.5.11 (CVE-2026-31802, CVE-2026-29786)
- Upgrade Node base image from 20-alpine to 22-alpine to fix npm
  bundled tar/minimatch CVEs in the Docker image
2026-03-16 16:25:29 +01:00
Paul Nothaft 48a025b915 Merge pull request #236 from the-luap/release-please--branches--beta
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(beta): release 3.18.1-beta.0
2026-03-16 15:02:04 +01:00
github-actions[bot] c652ae0ead chore(beta): release 3.18.1-beta.0 2026-03-16 14:01:13 +00:00
Paul Nothaft 9a6d2e8e3a Merge pull request #235 from the-luap/fix/email-preview-wrapper
fix: wrap email preview with full styled header/footer template
2026-03-16 15:00:56 +01:00
Paul Nothaft fc0911acf8 fix: wrap email preview with full styled header/footer template
The email template preview modal was showing only raw body HTML without
the styled wrapper (green header bar, logo, footer with company name)
that processTemplate() applies when sending. This made preview not match
what recipients actually receive.

Extract wrapEmailHtml() from processTemplate() and reuse it in the
preview endpoint. Also fix logo URL to use FRONTEND_URL consistently.

Closes #229
2026-03-16 15:00:37 +01:00
Paul Nothaft f77802325a Merge pull request #234 from the-luap/release-please--branches--beta
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(beta): release 3.18.0-beta.0
2026-03-16 10:22:14 +01:00
github-actions[bot] 74c9a5fbcd chore(beta): release 3.18.0-beta.0 2026-03-16 08:38:17 +00:00
Paul Nothaft 703c03fbee Merge pull request #233 from the-luap/feat/visual-email-editor
feat: visual WYSIWYG email template editor
2026-03-16 09:37:57 +01:00
Paul Nothaft 6f95b8c26c feat: register Russian locale and add to language selector
Import ru.json translations in i18n config and add Russian with flag
to the language selector dropdown.
2026-03-16 09:35:03 +01:00
Paul Nothaft 7250c427b9 fix: shorten Save button label on email template editor
Change "Save Changes" to "Save" for cleaner toolbar layout.
2026-03-16 09:29:24 +01:00
Paul Nothaft 04a7ea80f9 feat: add visual WYSIWYG email template editor (#229)
Replace raw HTML textarea with TipTap-based rich text editor for email
templates. Includes formatting toolbar, variable insertion dropdown,
source/visual toggle, and dark mode support. Add Mailhog service to
docker-compose for local email testing.
2026-03-15 22:05:27 +01:00
Paul Nothaft c0a5cd56c8 Merge pull request #232 from the-luap/i18n/ru-missing-keys
i18n: add missing Russian translations for thumbnails and photo dimensions
2026-03-15 20:12:22 +01:00
Paul Nothaft 908ab08815 Merge beta to resolve conflicts for PR #232 2026-03-15 20:02:27 +01:00
Paul Nothaft f07602553c Merge pull request #228 from the-luap/release-please--branches--beta
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(beta): release 3.17.2-beta.0
2026-03-11 21:54:58 +01:00
github-actions[bot] d2663bff81 chore(beta): release 3.17.2-beta.0 2026-03-11 19:48:06 +00:00
Paul Nothaft 308e086263 Merge pull request #226 from the-luap/fix/security-reporting-policy
fix: update security policy with private reporting channels
2026-03-11 20:47:51 +01:00
133 changed files with 13856 additions and 1626 deletions
+28
View File
@@ -7,6 +7,34 @@ NODE_ENV=production
# JWT Secret (generate with: openssl rand -base64 64)
JWT_SECRET=your_very_long_random_jwt_secret_here
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
# auto - decide per request: Secure on HTTPS, not on HTTP
#
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
# (via reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
# HTTP (e.g. LAN access at http://192.168.x.x:3010). The backend reads
# req.secure from Express, which respects the X-Forwarded-Proto header
# when the proxy is in the trust list.
#
# Requirements for auto mode:
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
# 192.168.x, link-local). Proxies outside those ranges need custom
# trust proxy configuration.
# COOKIE_SECURE=auto
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
# COOKIE_SAMESITE=Lax
# Cookie Domain — set this if serving auth cookies across subdomains.
# Leave unset for same-origin setups.
# COOKIE_DOMAIN=.example.com
# Database Configuration (PostgreSQL)
DATABASE_CLIENT=pg
DB_USER=picpeak
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.17.1-beta.0"
".": "3.28.2-beta.0"
}
+437 -1
View File
@@ -5,13 +5,449 @@ 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).
## [2.6.1](https://github.com/the-luap/picpeak/compare/v2.6.0...v2.6.1) (2026-03-11)
## [3.28.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.28.1-beta.0...v3.28.2-beta.0) (2026-04-12)
### Bug Fixes
* 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))
## [3.28.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.28.0-beta.0...v3.28.1-beta.0) (2026-04-12)
### Bug Fixes
* 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))
## [3.28.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.27.0-beta.0...v3.28.0-beta.0) (2026-04-11)
### Features
* 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))
### Bug Fixes
* 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))
## [3.27.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.26.2-beta.0...v3.27.0-beta.0) (2026-04-11)
### Features
* add admin dark mode and SEO/robots.txt settings ([9c2a0d2](https://github.com/the-luap/picpeak/commit/9c2a0d272a21dfcace2ec795034e2f1adcba47e0))
* add Apple Liquid Glass templates, image security settings, and automated releases ([6033461](https://github.com/the-luap/picpeak/commit/6033461be118ce78277ec568e1ef1ceeff7311c8))
* 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 CSS template system with custom gallery styling support ([0da45e6](https://github.com/the-luap/picpeak/commit/0da45e699ad998031aa56a92f2da5ee61a04e285))
* 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 event management, gallery customization, and release automationFeature/event rename ([40ee671](https://github.com/the-luap/picpeak/commit/40ee67171d41522037bf9d4e7675b62ec564346d))
* 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 multi-administrator support with RBAC and fix backup/restore for S3 ([892e47d](https://github.com/the-luap/picpeak/commit/892e47d017064d7922536f8e138bbb290a45cdc9))
* 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))
* **admin:** refine header layout and logo placement ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
* allow admin email updates in UI ([#36](https://github.com/the-luap/picpeak/issues/36)) ([3c2a79a](https://github.com/the-luap/picpeak/commit/3c2a79a31a0f1a44c8ec4f9a87f6fbcea9be651c))
* beta/stable release channels with update notifications and bug fixes ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
* beta/stable release channels with update notifications and bug fixes ([#98](https://github.com/the-luap/picpeak/issues/98)) ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
* 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))
* decouple hero header from gallery layouts ([#158](https://github.com/the-luap/picpeak/issues/158)) ([7b8d8bd](https://github.com/the-luap/picpeak/commit/7b8d8bd92ba7a96717bb4d821b38dddc395f701a))
* **docker:** add PUID/PGID and user mapping to avoid bind mount permission issues; feat(setup): prompt for admin email interactively; docs: PUID/PGID in .env.example ([410a33f](https://github.com/the-luap/picpeak/commit/410a33fecf1693cc75816c53ac460ec20089e2a1))
* 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))
* **events:** add CSS template selector to event edit page ([6a6c2cd](https://github.com/the-luap/picpeak/commit/6a6c2cd34db26a53b5fb96415650e8136a74e47f))
* 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/filters:** add Rated and Commented filters (UI + backend).\n\n- UI: add star (Rated) and message (Commented) buttons to feedback filter bars (desktop + mobile)\n- Backend: support filter=rated, commented, and combinations via aggregate counts/queries ([b03760a](https://github.com/the-luap/picpeak/commit/b03760ab01e21feb3578f90d065945d437d03452))
* **gallery:** add quick Like/Favorite actions on thumbnails across layouts ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a))
* **gallery:** always-visible feedback indicators on grid tiles; fallback image rendering in lightbox/hero; auto-auth from shared-link token; fix external photo resolver\n\n- GridGallery: bottom-left icons for like/rated/comment on every tile\n- Hero layout grid: added same indicators (non-intrusive icons)\n- Lightbox/Hero: add fallbackSrc to display thumbnail if original fails\n- GalleryAuth: auto-store token from /gallery/:slug/:token and hydrate event\n- Backend gallery photo route: use resolvePhotoFilePath for external-media\n\nfix(admin): move photo feedback badges to bottom-right on admin grid tiles\n\nfix(dashboard): add missing i18n keys for activity types + fallback to formatter\n\nfix(admin/feedback): correct thumbnail URL base + robust date parsing\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([6948aaa](https://github.com/the-luap/picpeak/commit/6948aaa92afc29609f85cf7fd631095f3e32ad3f))
* **gallery:** compact vertical icon-only feedback filter in PhotoFilterBar; remove wide buttons to prevent overflow\n\n- Desktop: vertical icon stack (All/Grid, Likes, Favorites) outside scroll area\n- Mobile: vertical icon stack below categories\n- Keeps existing category bar layout and count\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([465f997](https://github.com/the-luap/picpeak/commit/465f997752fc930ac0a3ae530e9e57a378877d53))
* **i18n:** add translations for settings tabs ([c030e87](https://github.com/the-luap/picpeak/commit/c030e872135b39701ef1f4bbb2f28bcaf4ce7fae))
* implement 4 new features with bug fixes and refactoring plan ([77a4bfd](https://github.com/the-luap/picpeak/commit/77a4bfd49975551bf509354097f280cab3e48c7a))
* implement beta/stable release channels with update notifications ([617e778](https://github.com/the-luap/picpeak/commit/617e778a48e0f0c24fcb8441d00ed2a816f19c03))
* 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))
* **lightbox:** keep feedback usable while navigating ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
* Multi-administrator RBAC, CSS templates & security hardening ([#78](https://github.com/the-luap/picpeak/issues/78)) ([16b3ab0](https://github.com/the-luap/picpeak/commit/16b3ab039ae95f5641dc15a4811eb2b503f1791c))
* 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:** auto-serve SPA when dist exists (unless SERVE_FRONTEND=false); add clear logging; serve index.html for /admin ([fb16b7b](https://github.com/the-luap/picpeak/commit/fb16b7bbb8225192160c08050f1b164c36c8dc74))
* **native:** build frontend and serve SPA from backend (SERVE_FRONTEND); fix Cannot GET /admin on native installs ([9fe10bc](https://github.com/the-luap/picpeak/commit/9fe10bcce2871a48f2409b4936d95c00249deb51))
* **native:** serve built frontend from backend; build frontend during install/update; ensure env flags (SERVE_FRONTEND, FRONTEND_DIR) ([61ad2d6](https://github.com/the-luap/picpeak/commit/61ad2d61c137196c229817989f991e50fa389a6e))
* new features and bug fixes for beta release ([151e1bf](https://github.com/the-luap/picpeak/commit/151e1bf50f206ae0571fa044c75b8bc9f0f40120))
* 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))
* overhaul public landing page and backup tooling ([2a4d388](https://github.com/the-luap/picpeak/commit/2a4d38813f7ab64a6bbb3a666f3c98a29443488d))
* 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))
* register Russian locale and add to language selector ([6f95b8c](https://github.com/the-luap/picpeak/commit/6f95b8c26cd794525e15e45d478f9ead0ec22555))
* **select:** add per-tile checkbox selection in Admin grid and all gallery layouts; tile click opens viewer; checkbox toggles selection; auto-enable selection mode; add testids ([9fda54b](https://github.com/the-luap/picpeak/commit/9fda54bd06d37cd8f8f71056bf4f59e158cd8112))
* **setup/docker:** auto-set PUID/PGID from invoking user and chown bind-mount folders; create missing data/events dirs ([0618b78](https://github.com/the-luap/picpeak/commit/0618b78725e85f97f0a4b4e834c17811c033c8f4))
* **setup:** remove --admin-password; print admin credentials from ADMIN_CREDENTIALS.txt; fix ADMIN_URL to avoid /admin/admin; update native service commands ([84d0f63](https://github.com/the-luap/picpeak/commit/84d0f63d36c68532fea83e7087b1afeaa9b82f39))
* 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 per-gallery password toggle ([5d6c061](https://github.com/the-luap/picpeak/commit/5d6c061f1c4fd20581b1e74fa114c96530b5de53))
* 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))
### Bug Fixes
* 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 settings translations and fix manual backup process ([#82](https://github.com/the-luap/picpeak/issues/82)) ([476fcce](https://github.com/the-luap/picpeak/commit/476fcce13f30f9f2d2f98a0c87c25fba09e9eebc))
* 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 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/feedback:** use correct event id when rendering photo thumbnails ([4c7b49a](https://github.com/the-luap/picpeak/commit/4c7b49a5f69a3fce4f9a0e837a082b56bb7e47d6)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
* **admin:** prevent category badge overlap in grid ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
* align backend port to 3000 across all configurations ([3a8d53f](https://github.com/the-luap/picpeak/commit/3a8d53f4927f577c4031c4bc3531e08191dc632a))
* Align nginx backend port for production Docker deployments (v2.2.2) ([#88](https://github.com/the-luap/picpeak/issues/88)) ([e0bd19a](https://github.com/the-luap/picpeak/commit/e0bd19a74dd81bdd45be2384820830bd96769e1c))
* 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))
* **backup:** add lastBackup alias and totalBackups for frontend compatibility ([749100c](https://github.com/the-luap/picpeak/commit/749100c92abd2bb123b137e3d3c6bb342b8f5f00))
* **backup:** allow manual backups when automated backups are disabled ([e6dd89e](https://github.com/the-luap/picpeak/commit/e6dd89e969fb7018633159155975bd2bd2fb0409))
* 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)
* CI workflow fixes for protected branches ([657c205](https://github.com/the-luap/picpeak/commit/657c205a4d8ca49070b69973f4c7a3d1418633af))
* CI workflow fixes for protected branches ([cb01218](https://github.com/the-luap/picpeak/commit/cb012186d93403a1ac4e2d2f5283319603b290d6))
* **ci:** add QEMU setup for multi-arch builds and skip for PRs ([0d36a27](https://github.com/the-luap/picpeak/commit/0d36a273bb58ffd0172efacd828e7171d954b41c))
* clear notifications via API ([#35](https://github.com/the-luap/picpeak/issues/35)) ([013be18](https://github.com/the-luap/picpeak/commit/013be18d982986333e2ac24c7ede907de49690bc))
* 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))
* **cors:** scope CORS to /api only and avoid throwing on disallowed origins; prevents static asset 500s on native ([90bb21e](https://github.com/the-luap/picpeak/commit/90bb21e38bf1ba97e3fb8185b8d05f1296d745ee))
* database migration restart bug, lightbox loading spinner, and watermark cache invalidation ([7c58749](https://github.com/the-luap/picpeak/commit/7c5874980640ae8c3d1050ce24daeb0a2aeab7a3))
* **db:** improve PostgreSQL connection check in wait-for-db.sh ([e85a68a](https://github.com/the-luap/picpeak/commit/e85a68a386c72c276b4958599b5246e60dfac716))
* display new password after admin password reset ([bd8b885](https://github.com/the-luap/picpeak/commit/bd8b885f7f060160eb852870d143f25ce628f3db))
* 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 Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
* dynamic website title from branding settings ([4701edc](https://github.com/the-luap/picpeak/commit/4701edc12ecfab27cb2d1cfb0b4ed4fd53f56cc6))
* 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 without expiration date incorrectly shown as expired ([c4f16eb](https://github.com/the-luap/picpeak/commit/c4f16eb76c909158abdb63aa4cc22f817f274dc5))
* external media dimensions, theme race condition, email color customization ([dfae2c2](https://github.com/the-luap/picpeak/commit/dfae2c2bc6d86378c553cd847b439f7cb53a4f2a))
* **frontend:** add missing externalMedia service and mount admin external-media routes; verify Vite build ([ab324f1](https://github.com/the-luap/picpeak/commit/ab324f192859204a3ea3c129530ccfe8f5a36968))
* gallery thumbnails not loading (404 errors) [#96](https://github.com/the-luap/picpeak/issues/96) ([e3c3c4c](https://github.com/the-luap/picpeak/commit/e3c3c4c951c52de99bd0afd95b08d119153997b4))
* **gallery/filters:** always apply global liked/favorited filters by aggregate counts (ignore guest_id); resolves mismatch between client guest_id and server identifier ([526dcd8](https://github.com/the-luap/picpeak/commit/526dcd8dfc030d86143cee799a88a1004d96b116))
* **gallery/filters:** make feedback filters work globally when no guest_id is provided; remove guest_id from client photos query\n\n- Backend /api/gallery/:slug/photos: if filter present and guest_id missing, filter by like_count/favorite_count\n- Frontend useGalleryPhotos: stop passing random guestId (does not match server guest_identifier)\n\nThis makes Liked/Favorited filters reflect photos with aggregate feedback counts as expected. ([5b2561b](https://github.com/the-luap/picpeak/commit/5b2561b6f1da2665d6092ba954f8ff26df3959a4))
* **gallery/sidebar:** compact icon-only feedback filter in sidebar (vertical, small) to avoid overflow; use GalleryFilter variant=compact ([ff89f96](https://github.com/the-luap/picpeak/commit/ff89f96e31130f75bcd7a406c5d895eac17b65de))
* **gallery:** feedback filter headline + horizontal icons in sidebar (compact variant); ensure sidebar content scrolls (flex-col container) ([3a6d061](https://github.com/the-luap/picpeak/commit/3a6d06192a280ead8bd5d1fbfe06554e63f3346e))
* handle legacy non-JSON logo paths when replacing logo ([0d5ce48](https://github.com/the-luap/picpeak/commit/0d5ce48dccf0c61f210725ffae15dafc5e9f7cab))
* handle null dates in dashboard and gallery pages ([c5a8ffc](https://github.com/the-luap/picpeak/commit/c5a8ffc08cd4c53c37fe4fb9cde8519a68f1f343))
* harden gallery downloads and per-gallery auth ([fc1bf53](https://github.com/the-luap/picpeak/commit/fc1bf534129092ca3638e4a4bc47274cd297fa5f))
* 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))
* JSON serialize favicon and logo URLs for PostgreSQL storage ([b83f427](https://github.com/the-luap/picpeak/commit/b83f4272b584f937fea1f47656182e514b12d980))
* lightbox watermark loading, white label translations, and dynamic footer year ([3b720ed](https://github.com/the-luap/picpeak/commit/3b720ed56ecd2ded6aec57309f8c408c63a617ef))
* lightbox watermark loading, white label translations, and dynamic footer year ([ce8587b](https://github.com/the-luap/picpeak/commit/ce8587b24df3f53a11a74348eff8b5c5b96c5488))
* lightbox watermark loading, white label translations, and dynamic footer year ([#108](https://github.com/the-luap/picpeak/issues/108)) ([3b720ed](https://github.com/the-luap/picpeak/commit/3b720ed56ecd2ded6aec57309f8c408c63a617ef))
* 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))
* Multi-administrator RBAC, CSS templates & security hardening ([#80](https://github.com/the-luap/picpeak/issues/80)) ([37d4e1c](https://github.com/the-luap/picpeak/commit/37d4e1cb6132346699a90aebfbaec83d84f931f4))
* **native/http:** disable CSP upgrade-insecure-requests and HSTS unless ENABLE_HSTS=true; prevents HTTPS upgrades on HTTP installs ([24b4a31](https://github.com/the-luap/picpeak/commit/24b4a314a9e97b6c640ca29067e95028a23a8973))
* **native:** correct setup paths to /opt/picpeak/app, update repo URL, add sqlite prod support; docs path fixes ([b992b15](https://github.com/the-luap/picpeak/commit/b992b151d3ca6ccb4a9b2434d94edcdc90ada3b0))
* **native:** remove obsolete workers service; restart only backend; add API request logging and preflight handler; keep static assets outside CORS ([f3604b4](https://github.com/the-luap/picpeak/commit/f3604b438b37e5f2bddf98e79f458bfa2367cb75))
* **nginx:** add Docker DNS resolver for Swarm/dynamic service discovery ([049837f](https://github.com/the-luap/picpeak/commit/049837f9d675ff5a4d93c02e5eb771bf65bc2616))
* **nginx:** Add Docker DNS resolver for Swarm/dynamic service discovery (v2.2.3) ([cc1ddfd](https://github.com/the-luap/picpeak/commit/cc1ddfd42cccac07d5869fe2ee19c25a9ffa50e8))
* **photos:** category changes now persist and display correctly ([#77](https://github.com/the-luap/picpeak/issues/77)) ([d9da98c](https://github.com/the-luap/picpeak/commit/d9da98c355011c247c526b28e6f07b329a632b55))
* **photos:** resolve upload category selection and improve feedback buttons ([#77](https://github.com/the-luap/picpeak/issues/77)) ([856d533](https://github.com/the-luap/picpeak/commit/856d53343c6805706e1498892a29b120938f8547))
* 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))
* prefer admin token on admin routes ([#23](https://github.com/the-luap/picpeak/issues/23) [#28](https://github.com/the-luap/picpeak/issues/28)) ([d4404e3](https://github.com/the-luap/picpeak/commit/d4404e39bd7953649da02d3e300ffef46573ac97))
* prevent database migration restart failures ([83a4344](https://github.com/the-luap/picpeak/commit/83a4344a01de4f65c5024fdf2d177a04457ccd2f)), closes [#107](https://github.com/the-luap/picpeak/issues/107)
* prevent unnecessary image recompression and fix SQLite migration [#95](https://github.com/the-luap/picpeak/issues/95) ([3cdc0ea](https://github.com/the-luap/picpeak/commit/3cdc0ea7152e63cd72124a91394741a6e6904af3))
* 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 branding display issues and invitation parsing errors ([1931d73](https://github.com/the-luap/picpeak/commit/1931d73b60d3419203cc8b420841abbfc9e14d2d))
* Resolve branding display issues and invitation parsing errors (v2.2.1) ([#86](https://github.com/the-luap/picpeak/issues/86)) ([d7ecf83](https://github.com/the-luap/picpeak/commit/d7ecf83d32ec6608280b96e6cdee48e9a0ad0afa))
* 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))
* **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 ([7ca9631](https://github.com/the-luap/picpeak/commit/7ca96315e254eef58d8ecc505f95a5186d2fa2da))
* **security:** upgrade Alpine base image to fix libpng and c-ares CVEs ([b706eeb](https://github.com/the-luap/picpeak/commit/b706eeb5d332e9618706193976a7241aee53d879))
* 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))
* **setup/native:** correct repo URL, paths, and systemd for native install; support sqlite in production knex config ([87b8414](https://github.com/the-luap/picpeak/commit/87b8414e449802db6dc9f762453f7672616b83c9))
* **setup/native:** Debian 12 compatibility (reliable RAM detection, sudo-less run_as_user, git safe.directory); ensure SQLite data dir; use user for migrate ([dc482e6](https://github.com/the-luap/picpeak/commit/dc482e614a5fbac44c6570d812669511301a4403))
* **setup/native:** handle forced updates safely by fetch+checkout/reset instead of pull; stable on rewritten histories ([3697344](https://github.com/the-luap/picpeak/commit/3697344cd0add28b4da71c3b33e2ccc0a96f50f9))
* **setup/update:** detect native installs first (/opt/picpeak/app/backend or systemd unit); avoid false docker updates on root ([adf576f](https://github.com/the-luap/picpeak/commit/adf576fbe17f40c13c1d77dd9751f2e9dbf523a1))
* 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)
* stabilize uploads and guest feedback filters ([aaaf598](https://github.com/the-luap/picpeak/commit/aaaf59817b3978635d2282c006853e183ab944d4))
* 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))
* 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))
* use Release Please extra-files instead of sync-versions job ([fe7d45d](https://github.com/the-luap/picpeak/commit/fe7d45dd122b2dca1b2a21ba5c86d32b9a193074))
* 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))
* watermark thumbnails, custom logo display, and German translations ([f843e4c](https://github.com/the-luap/picpeak/commit/f843e4c25cef02eef354fd3ee25824e20e4f8fc8))
* watermark thumbnails, custom logo display, and German translations ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6))
* watermark upload JSON parsing and image quality preservation ([0e3b50d](https://github.com/the-luap/picpeak/commit/0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a))
* 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))
### Documentation
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
* add PUID/PGID note for Docker bind mounts to avoid permission issues ([0178e71](https://github.com/the-luap/picpeak/commit/0178e71c67f198c6013ece52b0a2da0e2f1a6b2a))
* 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))
* **readme:** reflect new External Media reference mode and update roadmap (gallery feedback status) ([ee13556](https://github.com/the-luap/picpeak/commit/ee13556c5cb4f24fe88e14fd00b821acf65b11cb))
## [3.26.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.26.1-beta.0...v3.26.2-beta.0) (2026-04-11)
### Bug Fixes
* 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))
* 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))
## [3.26.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.26.0-beta.0...v3.26.1-beta.0) (2026-04-09)
### Bug Fixes
* 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))
* 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))
* 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))
### Documentation
* 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))
## [3.26.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.25.0-beta.0...v3.26.0-beta.0) (2026-04-09)
### Features
* 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))
### Bug Fixes
* 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)
## [3.25.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.24.1-beta.0...v3.25.0-beta.0) (2026-04-08)
### Features
* 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))
## [3.24.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.24.0-beta.0...v3.24.1-beta.0) (2026-04-05)
### Bug Fixes
* 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))
## [3.24.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.23.0-beta.0...v3.24.0-beta.0) (2026-04-04)
### Features
* 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))
## [3.23.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.22.0-beta.0...v3.23.0-beta.0) (2026-04-04)
### Features
* 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))
### Bug Fixes
* 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))
## [3.22.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.21.1-beta.0...v3.22.0-beta.0) (2026-03-25)
### Features
* 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))
## [3.21.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.21.0-beta.0...v3.21.1-beta.0) (2026-03-22)
### Bug Fixes
* 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))
## [3.21.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.20.1-beta.0...v3.21.0-beta.0) (2026-03-18)
### Features
* 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))
### Bug Fixes
* 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))
## [3.20.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.20.0-beta.0...v3.20.1-beta.0) (2026-03-17)
### Bug Fixes
* 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))
## [3.20.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.19.2-beta.0...v3.20.0-beta.0) (2026-03-17)
### Features
* 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))
## [3.19.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.19.1-beta.0...v3.19.2-beta.0) (2026-03-16)
### Bug Fixes
* **security:** invalidate tokens on password change, enforce session timeout, fix role update ([f362239](https://github.com/the-luap/picpeak/commit/f3622396e77ce5d0b0741e439fc554a1dccaca50))
* **security:** token invalidation on password change, session timeout enforcement ([7ca9631](https://github.com/the-luap/picpeak/commit/7ca96315e254eef58d8ecc505f95a5186d2fa2da))
## [3.19.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.19.0-beta.0...v3.19.1-beta.0) (2026-03-16)
### Bug Fixes
* external media dimensions, theme race condition, email color customization ([dfae2c2](https://github.com/the-luap/picpeak/commit/dfae2c2bc6d86378c553cd847b439f7cb53a4f2a))
* resolve external media dimensions, gallery theme race condition, and add email color customization ([bbeedd1](https://github.com/the-luap/picpeak/commit/bbeedd1888561b6c57586b5f42bbfee3ffc69fd7))
## [3.19.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.18.2-beta.0...v3.19.0-beta.0) (2026-03-16)
### Features
* 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))
## [3.18.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.18.1-beta.0...v3.18.2-beta.0) (2026-03-16)
### Bug Fixes
* resolve code scanning security alerts (multer, tar, Node 22) ([85a07fc](https://github.com/the-luap/picpeak/commit/85a07fcca7ad935f4c0c300f5ffe2f3af8da1e5f))
* update dependencies to resolve code scanning security alerts ([1f524f2](https://github.com/the-luap/picpeak/commit/1f524f23580d2e2a21dbba28cb46aed76e85c475))
## [3.18.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.18.0-beta.0...v3.18.1-beta.0) (2026-03-16)
### Bug Fixes
* 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)
## [3.18.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.2-beta.0...v3.18.0-beta.0) (2026-03-16)
### Features
* add visual WYSIWYG email template editor ([#229](https://github.com/the-luap/picpeak/issues/229)) ([04a7ea8](https://github.com/the-luap/picpeak/commit/04a7ea80f95d6aeb474b145292e75f45fb85c66d))
* register Russian locale and add to language selector ([6f95b8c](https://github.com/the-luap/picpeak/commit/6f95b8c26cd794525e15e45d478f9ead0ec22555))
* visual WYSIWYG email template editor ([703c03f](https://github.com/the-luap/picpeak/commit/703c03fbee754a5291b57b885c5e82fbdd3e69e9))
### Bug Fixes
* shorten Save button label on email template editor ([7250c42](https://github.com/the-luap/picpeak/commit/7250c427b905ffa3e8696dff607450f5a0b801b8))
## [3.17.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.1-beta.0...v3.17.2-beta.0) (2026-03-11)
### Bug Fixes
* update security policy with private reporting channels ([308e086](https://github.com/the-luap/picpeak/commit/308e08626383bab213ce3eb5563608dff6168ef4))
* 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)
* 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))
## [3.17.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.17.0-beta.0...v3.17.1-beta.0) (2026-03-08)
+21 -14
View File
@@ -219,29 +219,36 @@ location ~ ^/(photos|thumbnails|uploads) {
### Creating a Gallery
#### Method 1: Via Admin Panel (Recommended)
1. Login to admin panel
#### Via Admin Panel
1. Login to admin panel at `/admin`
2. Click "Create New Event"
3. Configure settings and upload photos
3. Configure settings (name, date, password, customer email)
4. Upload photos via drag & drop in the Photos tab
5. Publish the gallery when ready
#### Adding Photos via File System
> **Important:** You must first create the event in the admin panel. The file watcher only detects new photos for events that already exist in the database. You cannot create a gallery by copying files alone.
Once an event exists, you can add photos by copying them into the event's folder. PicPeak's built-in file watcher will automatically detect the new files, create database records, and generate thumbnails.
#### Method 2: File System
```bash
# Docker installation
mkdir -p ~/picpeak/storage/events/active/wedding-smith-2024
cp /path/to/photos/* ~/picpeak/storage/events/active/wedding-smith-2024/
# Docker installation — copy photos into an existing event's folder
cp /path/to/photos/*.jpg ~/picpeak/storage/events/active/<event-slug>/
# Native installation
sudo mkdir -p /opt/picpeak/events/active/wedding-smith-2024
sudo cp /path/to/photos/* /opt/picpeak/events/active/wedding-smith-2024/
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/wedding-smith-2024
sudo cp /path/to/photos/*.jpg /opt/picpeak/events/active/<event-slug>/
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/<event-slug>
```
The event slug is visible in the admin panel URL or share link (e.g. `wedding-smith-2024`). Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`. The file watcher has a 2-second stability delay before processing new files.
### Gallery Structure
```
wedding-smith-2024/
├── collages/ # Group photos
├── individual/ # Individual photos
└── thumbnails/ # Auto-generated thumbnails
<event-slug>/
├── collages/ # Group photos (optional subfolder)
├── individual/ # Individual photos (optional subfolder)
└── photo.jpg # Photos at root level also work
```
## 🔧 Service Management
+28
View File
@@ -9,6 +9,34 @@ PORT=3001
# Generate with: openssl rand -base64 32
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
# auto - decide per request: Secure on HTTPS, not on HTTP
#
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
# (via a reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
# HTTP (e.g. LAN access at http://192.168.x.x:3001). The backend reads
# req.secure from Express, which respects the X-Forwarded-Proto header
# when the proxy is in the trust list.
#
# Requirements for auto mode:
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
# 192.168.x, link-local). Proxies outside those ranges need custom
# trust proxy configuration.
# COOKIE_SECURE=auto
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
# COOKIE_SAMESITE=Lax
# Cookie Domain — set this if serving auth cookies across subdomains.
# Leave unset for same-origin setups.
# COOKIE_DOMAIN=.example.com
# URLs (adjust for your domain)
ADMIN_URL=https://photos.example.com
FRONTEND_URL=https://photos.example.com
+5 -4
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS builder
FROM node:22-alpine AS builder
# Add build arguments
ARG CACHEBUST=1
@@ -23,15 +23,16 @@ RUN npm ci --omit=dev
COPY . .
# Production stage
FROM node:20-alpine
FROM node:22-alpine
WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN apk upgrade --no-cache
# Upgrade npm to latest to fix tar, minimatch, brace-expansion CVEs in npm's own deps
RUN npm install -g npm@latest
# Upgrade npm to fix tar, minimatch, brace-expansion CVEs in npm's own deps
# Pin to 10.x to stay compatible with Node 22 Alpine (npm 11.x has dependency issues)
RUN npm install -g npm@10
# Install dumb-init for proper signal handling and postgresql-client for database checks
RUN apk add --no-cache dumb-init postgresql-client
@@ -0,0 +1,13 @@
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 074_add_photo_cap');
await addColumnIfNotExists(knex, 'events', 'photo_cap', (table) => {
table.integer('photo_cap').nullable().defaultTo(null);
});
console.log('Migration 074_add_photo_cap completed');
};
exports.down = async function(knex) {
console.log('Rollback: 074_add_photo_cap');
};
@@ -0,0 +1,28 @@
const { addColumnIfNotExists, createIndexIfNotExists } = require('../helpers');
exports.up = async function(knex) {
// Add visibility column to photos table
await addColumnIfNotExists(knex, 'photos', 'visibility', (table) => {
table.string('visibility', 20).defaultTo('visible').notNullable();
});
// Add client access columns to events table
await addColumnIfNotExists(knex, 'events', 'client_access_enabled', (table) => {
table.boolean('client_access_enabled').defaultTo(false);
});
await addColumnIfNotExists(knex, 'events', 'client_password_hash', (table) => {
table.string('client_password_hash', 255).nullable();
});
await addColumnIfNotExists(knex, 'events', 'client_share_token', (table) => {
table.string('client_share_token', 64).nullable().unique();
});
// Index for filtering photos by visibility
await createIndexIfNotExists(knex, 'photos', ['event_id', 'visibility'], 'idx_photos_event_visibility');
};
exports.down = async function(knex) {
// Safe rollback - intentionally no-op to avoid data loss
};
@@ -0,0 +1,281 @@
/**
* Migration to create email_template_translations table
* Moves from per-column language support (subject_en, subject_de) to a
* normalized translations table where each language is a row.
* This allows adding new languages without schema changes.
*/
exports.up = async function(knex) {
// 1. Create the email_template_translations table
await knex.schema.createTable('email_template_translations', (table) => {
table.increments('id').primary();
table.integer('template_id').unsigned().notNullable()
.references('id').inTable('email_templates').onDelete('CASCADE');
table.string('language', 10).notNullable();
table.text('subject');
table.text('body_html');
table.text('body_text');
table.datetime('created_at').defaultTo(knex.fn.now());
table.datetime('updated_at').defaultTo(knex.fn.now());
table.unique(['template_id', 'language']);
});
console.log('Created email_template_translations table');
// 2. Migrate existing data from email_templates columns into rows
const templates = await knex('email_templates').select('*');
const columnInfo = await knex('email_templates').columnInfo();
const hasLangColumns = !!columnInfo.subject_en;
for (const template of templates) {
// Extract EN translation
const enSubject = hasLangColumns
? (template.subject_en || template.subject || '')
: (template.subject || '');
const enHtml = hasLangColumns
? (template.body_html_en || template.body_html || '')
: (template.body_html || '');
const enText = hasLangColumns
? (template.body_text_en || template.body_text || '')
: (template.body_text || '');
// Insert EN translation
if (enSubject || enHtml) {
await knex('email_template_translations').insert({
template_id: template.id,
language: 'en',
subject: enSubject,
body_html: enHtml,
body_text: enText,
created_at: new Date(),
updated_at: new Date(),
});
}
// Extract DE translation (only if lang columns exist)
if (hasLangColumns) {
const deSubject = template.subject_de || '';
const deHtml = template.body_html_de || '';
const deText = template.body_text_de || '';
// Only insert if DE content differs from EN or has content
if (deSubject || deHtml) {
await knex('email_template_translations').insert({
template_id: template.id,
language: 'de',
subject: deSubject,
body_html: deHtml,
body_text: deText,
created_at: new Date(),
updated_at: new Date(),
});
}
}
}
console.log(`Migrated ${templates.length} templates to translations table`);
// 3. Seed NL, PT, RU translations for customer-facing templates
// Look up template IDs
const customerTemplates = await knex('email_templates')
.whereIn('template_key', [
'gallery_created', 'expiration_warning', 'gallery_expired', 'archive_complete'
])
.select('id', 'template_key');
const templateMap = {};
customerTemplates.forEach(t => { templateMap[t.template_key] = t.id; });
const seedTranslations = [];
// --- gallery_created ---
if (templateMap.gallery_created) {
const id = templateMap.gallery_created;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Uw fotogalerij is klaar!',
body_html: `<h2>Galerij succesvol aangemaakt</h2>
<p>Beste {{host_name}},</p>
<p>Uw fotogalerij "{{event_name}}" is succesvol aangemaakt!</p>
<p><strong>Galerij details:</strong></p>
<ul>
<li>Evenementdatum: {{event_date}}</li>
<li>Galerij link: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Wachtwoord: {{gallery_password}}</li>
<li>Verloopt op: {{expiry_date}}</li>
</ul>
<p>Deel deze link en het wachtwoord met uw gasten zodat zij de foto's kunnen bekijken en downloaden.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: `Galerij succesvol aangemaakt\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is succesvol aangemaakt!\n\nGalerij link: {{gallery_link}}\nWachtwoord: {{gallery_password}}\nVerloopt op: {{expiry_date}}`,
},
{
template_id: id, language: 'pt',
subject: 'Sua galeria de fotos está pronta!',
body_html: `<h2>Galeria criada com sucesso</h2>
<p>Prezado(a) {{host_name}},</p>
<p>Sua galeria de fotos "{{event_name}}" foi criada com sucesso!</p>
<p><strong>Detalhes da galeria:</strong></p>
<ul>
<li>Data do evento: {{event_date}}</li>
<li>Link da galeria: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Senha: {{gallery_password}}</li>
<li>Expira em: {{expiry_date}}</li>
</ul>
<p>Compartilhe este link e senha com seus convidados para que possam visualizar e baixar as fotos.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: `Galeria criada com sucesso\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" foi criada com sucesso!\n\nLink da galeria: {{gallery_link}}\nSenha: {{gallery_password}}\nExpira em: {{expiry_date}}`,
},
{
template_id: id, language: 'ru',
subject: 'Ваша фотогалерея готова!',
body_html: `<h2>Галерея успешно создана</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Ваша фотогалерея "{{event_name}}" была успешно создана!</p>
<p><strong>Детали галереи:</strong></p>
<ul>
<li>Дата события: {{event_date}}</li>
<li>Ссылка на галерею: <a href="{{gallery_link}}">{{gallery_link}}</a></li>
<li>Пароль: {{gallery_password}}</li>
<li>Срок действия: {{expiry_date}}</li>
</ul>
<p>Поделитесь этой ссылкой и паролем с вашими гостями, чтобы они могли просматривать и скачивать фотографии.</p>
{{#if welcome_message}}<p><em>{{welcome_message}}</em></p>{{/if}}`,
body_text: `Галерея успешно создана\n\nУважаемый(ая) {{host_name}},\n\nВаша фотогалерея "{{event_name}}" была успешно создана!\n\nСсылка: {{gallery_link}}\nПароль: {{gallery_password}}\nСрок действия: {{expiry_date}}`,
},
);
}
// --- expiration_warning ---
if (templateMap.expiration_warning) {
const id = templateMap.expiration_warning;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Uw fotogalerij verloopt binnenkort',
body_html: `<h2>Galerij verloopt binnenkort</h2>
<p>Beste {{host_name}},</p>
<p>Uw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.</p>
<p>Na het verlopen wordt de galerij gearchiveerd en is niet meer toegankelijk voor gasten.</p>
<p><a href="{{gallery_link}}">Galerij bezoeken</a></p>`,
body_text: `Galerij verloopt binnenkort\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" verloopt over {{days_remaining}} dagen.\n\nGalerij: {{gallery_link}}`,
},
{
template_id: id, language: 'pt',
subject: 'Sua galeria de fotos expira em breve',
body_html: `<h2>Galeria expirando em breve</h2>
<p>Prezado(a) {{host_name}},</p>
<p>Sua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.</p>
<p>Após a expiração, a galeria será arquivada e não estará mais acessível aos convidados.</p>
<p><a href="{{gallery_link}}">Visitar galeria</a></p>`,
body_text: `Galeria expirando em breve\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirará em {{days_remaining}} dias.\n\nGaleria: {{gallery_link}}`,
},
{
template_id: id, language: 'ru',
subject: 'Срок действия вашей фотогалереи скоро истекает',
body_html: `<h2>Срок действия галереи истекает</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Срок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.</p>
<p>После истечения срока галерея будет архивирована и станет недоступна для гостей.</p>
<p><a href="{{gallery_link}}">Перейти в галерею</a></p>`,
body_text: `Срок действия галереи истекает\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истекает через {{days_remaining}} дней.\n\nГалерея: {{gallery_link}}`,
},
);
}
// --- gallery_expired ---
if (templateMap.gallery_expired) {
const id = templateMap.gallery_expired;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Uw fotogalerij {{event_name}} is verlopen',
body_html: `<h2>Galerij verlopen</h2>
<p>Beste {{host_name}},</p>
<p>Uw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.</p>
<p>De foto's zijn gearchiveerd. Als u toegang nodig heeft, neem dan contact op met de beheerder via {{admin_email}}.</p>`,
body_text: `Galerij verlopen\n\nBeste {{host_name}},\n\nUw fotogalerij "{{event_name}}" is verlopen en niet meer toegankelijk.\n\nNeem contact op met: {{admin_email}}`,
},
{
template_id: id, language: 'pt',
subject: 'Sua galeria de fotos {{event_name}} expirou',
body_html: `<h2>Galeria expirada</h2>
<p>Prezado(a) {{host_name}},</p>
<p>Sua galeria de fotos "{{event_name}}" expirou e não está mais acessível.</p>
<p>As fotos foram arquivadas. Se precisar de acesso, entre em contato com o administrador em {{admin_email}}.</p>`,
body_text: `Galeria expirada\n\nPrezado(a) {{host_name}},\n\nSua galeria de fotos "{{event_name}}" expirou e não está mais acessível.\n\nContato: {{admin_email}}`,
},
{
template_id: id, language: 'ru',
subject: 'Срок действия фотогалереи {{event_name}} истёк',
body_html: `<h2>Срок действия галереи истёк</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Срок действия вашей фотогалереи "{{event_name}}" истёк, и она больше недоступна.</p>
<p>Фотографии были архивированы. Если вам нужен доступ, свяжитесь с администратором: {{admin_email}}.</p>`,
body_text: `Срок действия галереи истёк\n\nУважаемый(ая) {{host_name}},\n\nСрок действия вашей фотогалереи "{{event_name}}" истёк.\n\nКонтакт: {{admin_email}}`,
},
);
}
// --- archive_complete ---
if (templateMap.archive_complete) {
const id = templateMap.archive_complete;
seedTranslations.push(
{
template_id: id, language: 'nl',
subject: 'Archivering voltooid: {{event_name}}',
body_html: `<h2>Archivering voltooid</h2>
<p>Beste {{host_name}},</p>
<p>De fotogalerij "{{event_name}}" is succesvol gearchiveerd.</p>
<p><strong>Archief details:</strong></p>
<ul>
<li>Aantal foto's: {{photo_count}}</li>
<li>Archiefgrootte: {{archive_size}}</li>
<li>Archiefdatum: {{archive_date}}</li>
</ul>`,
body_text: `Archivering voltooid\n\nBeste {{host_name}},\n\nDe fotogalerij "{{event_name}}" is succesvol gearchiveerd.\n\nAantal foto's: {{photo_count}}\nGrootte: {{archive_size}}`,
},
{
template_id: id, language: 'pt',
subject: 'Arquivamento concluído: {{event_name}}',
body_html: `<h2>Arquivamento concluído</h2>
<p>Prezado(a) {{host_name}},</p>
<p>A galeria de fotos "{{event_name}}" foi arquivada com sucesso.</p>
<p><strong>Detalhes do arquivo:</strong></p>
<ul>
<li>Número de fotos: {{photo_count}}</li>
<li>Tamanho do arquivo: {{archive_size}}</li>
<li>Data do arquivamento: {{archive_date}}</li>
</ul>`,
body_text: `Arquivamento concluído\n\nPrezado(a) {{host_name}},\n\nA galeria de fotos "{{event_name}}" foi arquivada com sucesso.\n\nFotos: {{photo_count}}\nTamanho: {{archive_size}}`,
},
{
template_id: id, language: 'ru',
subject: 'Архивация завершена: {{event_name}}',
body_html: `<h2>Архивация завершена</h2>
<p>Уважаемый(ая) {{host_name}},</p>
<p>Фотогалерея "{{event_name}}" была успешно архивирована.</p>
<p><strong>Детали архива:</strong></p>
<ul>
<li>Количество фото: {{photo_count}}</li>
<li>Размер архива: {{archive_size}}</li>
<li>Дата архивации: {{archive_date}}</li>
</ul>`,
body_text: `Архивация завершена\n\nУважаемый(ая) {{host_name}},\n\nФотогалерея "{{event_name}}" была успешно архивирована.\n\nФото: {{photo_count}}\nРазмер: {{archive_size}}`,
},
);
}
// Insert all seed translations
const now = new Date();
for (const trans of seedTranslations) {
trans.created_at = now;
trans.updated_at = now;
await knex('email_template_translations').insert(trans);
}
console.log(`Seeded ${seedTranslations.length} translations for customer-facing templates`);
};
exports.down = async function(knex) {
await knex.schema.dropTableIfExists('email_template_translations');
};
@@ -0,0 +1,21 @@
/**
* Migration to add is_draft column to events table.
* Draft events are not visible to gallery visitors until published.
*/
exports.up = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'is_draft');
if (!hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.boolean('is_draft').defaultTo(false);
});
}
};
exports.down = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'is_draft');
if (hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('is_draft');
});
}
};
@@ -0,0 +1,17 @@
exports.up = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'default_photo_sort');
if (!hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.string('default_photo_sort', 50).defaultTo('upload_date_desc');
});
}
};
exports.down = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'default_photo_sort');
if (hasColumn) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('default_photo_sort');
});
}
};
@@ -0,0 +1,120 @@
/**
* Add guest identity layer for per-person photo selections (issue #292).
*
* Adds:
* - gallery_guests — persistent guest profiles per event
* - guest_invites — pre-minted invite tokens (Phase 3.3)
* - guest_verification_codes — email-based identity recovery (Phase 3.2)
* - event_feedback_settings.identity_mode ('simple' | 'guest', default 'simple')
* - photo_feedback.guest_id FK — links feedback to gallery_guests (nullable)
*
* All changes are additive. Existing events default to 'simple' mode so behavior
* is unchanged. Legacy photo_feedback rows keep NULL guest_id.
*/
exports.up = async function(knex) {
// 1. gallery_guests — persistent per-person identity within an event.
const hasGalleryGuests = await knex.schema.hasTable('gallery_guests');
if (!hasGalleryGuests) {
await knex.schema.createTable('gallery_guests', (table) => {
table.increments('id').primary();
table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
table.string('name', 100).notNullable();
table.string('email', 255);
table.string('identifier', 64).notNullable(); // UUIDv4 issued server-side
table.string('ip_address_last', 45);
table.text('user_agent_last');
table.timestamp('email_verified_at');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('last_seen_at').defaultTo(knex.fn.now());
table.boolean('is_deleted').defaultTo(false);
table.unique(['event_id', 'identifier']);
table.index(['event_id']);
table.index(['event_id', 'email']);
});
}
// 2. guest_invites — pre-minted one-time-use tokens for invited guests.
const hasGuestInvites = await knex.schema.hasTable('guest_invites');
if (!hasGuestInvites) {
await knex.schema.createTable('guest_invites', (table) => {
table.increments('id').primary();
table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
table.integer('guest_id').notNullable().references('id').inTable('gallery_guests').onDelete('CASCADE');
table.string('token', 64).notNullable().unique();
table.integer('created_by_admin_id').references('id').inTable('admin_users');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('redeemed_at');
table.timestamp('revoked_at');
table.index(['event_id']);
table.index(['guest_id']);
});
}
// 3. guest_verification_codes — short-lived codes for email-based recovery.
const hasGuestVerificationCodes = await knex.schema.hasTable('guest_verification_codes');
if (!hasGuestVerificationCodes) {
await knex.schema.createTable('guest_verification_codes', (table) => {
table.increments('id').primary();
table.integer('event_id').notNullable().references('id').inTable('events').onDelete('CASCADE');
table.string('email', 255).notNullable();
table.string('code_hash', 128).notNullable(); // bcrypt hash of 6-digit code
table.integer('attempts').defaultTo(0);
table.timestamp('expires_at').notNullable();
table.timestamp('consumed_at');
table.timestamp('created_at').defaultTo(knex.fn.now());
table.index(['event_id', 'email']);
table.index(['expires_at']);
});
}
// 4. event_feedback_settings.identity_mode
const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode');
if (!hasIdentityMode) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.string('identity_mode', 16).notNullable().defaultTo('simple');
});
if (knex.client.config.client === 'pg') {
await knex.raw(`
ALTER TABLE event_feedback_settings
ADD CONSTRAINT event_feedback_settings_identity_mode_check
CHECK (identity_mode IN ('simple','guest'))
`);
}
}
// 5. photo_feedback.guest_id FK
const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id');
if (!hasGuestIdColumn) {
await knex.schema.alterTable('photo_feedback', (table) => {
table.integer('guest_id').references('id').inTable('gallery_guests').onDelete('SET NULL');
table.index(['guest_id']);
});
}
};
exports.down = async function(knex) {
const hasGuestIdColumn = await knex.schema.hasColumn('photo_feedback', 'guest_id');
if (hasGuestIdColumn) {
await knex.schema.alterTable('photo_feedback', (table) => {
table.dropColumn('guest_id');
});
}
if (knex.client.config.client === 'pg') {
await knex.raw('ALTER TABLE event_feedback_settings DROP CONSTRAINT IF EXISTS event_feedback_settings_identity_mode_check');
}
const hasIdentityMode = await knex.schema.hasColumn('event_feedback_settings', 'identity_mode');
if (hasIdentityMode) {
await knex.schema.alterTable('event_feedback_settings', (table) => {
table.dropColumn('identity_mode');
});
}
await knex.schema.dropTableIfExists('guest_verification_codes');
await knex.schema.dropTableIfExists('guest_invites');
await knex.schema.dropTableIfExists('gallery_guests');
};
+95 -60
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "2.5.0",
"version": "3.24.1-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "2.5.0",
"version": "3.24.1-beta.0",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -14,7 +14,7 @@
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
"axios": "^1.12.2",
"axios": "1.14.0",
"bcrypt": "6.0.0",
"chokidar": "4.0.3",
"cookie-parser": "^1.4.7",
@@ -26,7 +26,7 @@
"express-validator": "^7.0.1",
"fluent-ffmpeg": "^2.1.3",
"form-data": "^4.0.4",
"handlebars": "^4.7.8",
"handlebars": "^4.7.9",
"helmet": "^7.0.0",
"i18next": "25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
@@ -39,7 +39,7 @@
"mime-types": "^3.0.1",
"multer": "^2.0.2",
"node-cron": "^3.0.2",
"nodemailer": "^7.0.10",
"nodemailer": "^7.0.13",
"pg": "^8.16.3",
"react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
@@ -4071,14 +4071,14 @@
"license": "MIT"
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/babel-jest": {
@@ -4290,9 +4290,9 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
@@ -5788,6 +5788,12 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/express/node_modules/path-to-regexp": {
"version": "0.1.13",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
"node_modules/express/node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
@@ -5832,21 +5838,9 @@
"license": "MIT"
},
"node_modules/fast-xml-builder": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz",
"integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT"
},
"node_modules/fast-xml-parser": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz",
"integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==",
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz",
"integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==",
"funding": [
{
"type": "github",
@@ -5855,8 +5849,24 @@
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.0.0",
"strnum": "^2.1.2"
"path-expression-matcher": "^1.1.3"
}
},
"node_modules/fast-xml-parser": {
"version": "5.5.10",
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.10.tgz",
"integrity": "sha512-go2J2xODMc32hT+4Xr/bBGXMaIoiCwrwp2mMtAvKyvEFW6S/v5Gn2pBmE4nvbwNjGhpcAiOwEv7R6/GZ6XRa9w==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"dependencies": {
"fast-xml-builder": "^1.1.4",
"path-expression-matcher": "^1.2.1",
"strnum": "^2.2.2"
},
"bin": {
"fxparser": "src/cli/cli.js"
@@ -5986,9 +5996,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"dev": true,
"license": "ISC"
},
@@ -6418,9 +6428,9 @@
"license": "MIT"
},
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"version": "4.7.9",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
@@ -6664,6 +6674,19 @@
"cross-fetch": "4.0.0"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"license": "MIT",
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
@@ -7977,9 +8000,9 @@
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"license": "MIT"
},
"node_modules/lodash.defaults": {
@@ -8558,9 +8581,9 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.0.tgz",
"integrity": "sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==",
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
@@ -8760,9 +8783,9 @@
"license": "MIT"
},
"node_modules/nodemailer": {
"version": "7.0.12",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.12.tgz",
"integrity": "sha512-H+rnK5bX2Pi/6ms3sN4/jRQvYSMltV6vqup/0SFOrxYYY/qoNvhXPlYq3e+Pm9RFJRwrMGbMIwi81M4dxpomhA==",
"version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
"integrity": "sha512-PNDFSJdP+KFgdsG3ZzMXCgquO7I6McjY2vlqILjtJd0hy8wEvtugS9xKRF2NWlPNGxvLCXlTNIae4serI7dinw==",
"license": "MIT-0",
"engines": {
"node": ">=6.0.0"
@@ -9145,6 +9168,21 @@
"node": ">=8"
}
},
"node_modules/path-expression-matcher": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.4.0.tgz",
"integrity": "sha512-s4DQMxIdhj3jLFWd9LxHOplj4p9yQ4ffMGowFf3cpEgrrJjEhN0V5nxw4Ye1EViAGDoL4/1AeO6qHpqYPOzE4Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/NaturalIntelligence"
}
],
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
@@ -9185,12 +9223,6 @@
"node": "20 || >=22"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.16.3",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
@@ -9579,10 +9611,13 @@
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/pstree.remy": {
"version": "1.1.8",
@@ -10659,9 +10694,9 @@
}
},
"node_modules/strnum": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz",
"integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==",
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.3.tgz",
"integrity": "sha512-oKx6RUCuHfT3oyVjtnrmn19H1SiCqgJSg+54XqURKp5aCMbrXrhLjRN9TjuwMjiYstZ0MzDrHqkGZ5dFTKd+zg==",
"funding": [
{
"type": "github",
@@ -10760,9 +10795,9 @@
}
},
"node_modules/tar": {
"version": "7.5.9",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.9.tgz",
"integrity": "sha512-BTLcK0xsDh2+PUe9F6c2TlRp4zOOBMTkoQHQIWSIzI0R7KG46uEwq4OPk2W7bZcprBMsuaeFsqwYr7pjh6CuHg==",
"version": "7.5.13",
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.13.tgz",
"integrity": "sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
+10 -8
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "2.6.1",
"version": "3.28.2-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -19,7 +19,7 @@
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
"axios": "^1.12.2",
"axios": "1.14.0",
"bcrypt": "6.0.0",
"chokidar": "4.0.3",
"cookie-parser": "^1.4.7",
@@ -31,7 +31,7 @@
"express-validator": "^7.0.1",
"fluent-ffmpeg": "^2.1.3",
"form-data": "^4.0.4",
"handlebars": "^4.7.8",
"handlebars": "^4.7.9",
"helmet": "^7.0.0",
"i18next": "25.3.2",
"i18next-browser-languagedetector": "^8.2.0",
@@ -44,7 +44,7 @@
"mime-types": "^3.0.1",
"multer": "^2.0.2",
"node-cron": "^3.0.2",
"nodemailer": "^7.0.10",
"nodemailer": "^7.0.13",
"pg": "^8.16.3",
"react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
@@ -67,10 +67,12 @@
},
"glob": "^11.1.0",
"js-yaml": "^4.1.1",
"fast-xml-parser": ">=5.3.8",
"fast-xml-parser": ">=5.5.10",
"qs": ">=6.14.2",
"tar": ">=7.5.8",
"brace-expansion": ">=5.0.0",
"minimatch": ">=9.0.7"
"tar": ">=7.5.13",
"brace-expansion": ">=5.0.5",
"minimatch": ">=9.0.7",
"path-to-regexp": "0.1.13",
"lodash": ">=4.18.1"
}
}
+35 -8
View File
@@ -194,13 +194,23 @@ function composeInlineStyles(payload) {
return cssSegments.join('\n\n');
}
function escapeHtml(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function renderBrandHeader(branding) {
const displayName = branding.companyName || 'PicPeak';
const logoSrc = branding.logoUrl || '/picpeak-logo-transparent.png';
const displayName = escapeHtml(branding.companyName || 'PicPeak');
const logoSrc = encodeURI(branding.logoUrl || '/picpeak-logo-transparent.png');
const logo = `<img src="${logoSrc}" alt="${displayName}" class="brand-logo" loading="lazy" decoding="async" />`;
const tagline = branding.companyTagline
? `<p class="brand-tagline">${branding.companyTagline}</p>`
? `<p class="brand-tagline">${escapeHtml(branding.companyTagline)}</p>`
: '';
return `<header class="site-header">
@@ -224,13 +234,14 @@ function renderBrandHeader(branding) {
}
function renderBrandFooter(branding) {
const displayName = branding.companyName || 'PicPeak';
const displayName = escapeHtml(branding.companyName || 'PicPeak');
const footerNote = branding.footerText
? `<p>${branding.footerText}</p>`
? `<p>${escapeHtml(branding.footerText)}</p>`
: '<p>Powered by PicPeak to keep every celebration beautifully organised.</p>';
const supportLink = branding.supportEmail
? `<a href="mailto:${branding.supportEmail}">Support</a>`
const supportEmail = escapeHtml(branding.supportEmail || '');
const supportLink = supportEmail
? `<a href="mailto:${supportEmail}">Support</a>`
: '';
const legalLinks = `
@@ -282,7 +293,7 @@ function buildPublicSiteDocument(payload) {
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>${payload.title}</title>
<title>${escapeHtml(payload.title)}</title>
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
${seoMeta}
<link rel="preconnect" href="https://fonts.googleapis.com" />
@@ -362,6 +373,20 @@ async function initializeRateLimiters() {
app.use(express.json({ limit: '50mb' }));
app.use(express.urlencoded({ extended: true, limit: '50mb' }));
// CSRF protection: require JSON Content-Type on mutating API requests
// This blocks cross-origin form submissions which cannot set Content-Type: application/json
app.use('/api', (req, res, next) => {
if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(req.method)) {
const contentType = req.headers['content-type'] || '';
const contentLength = parseInt(req.headers['content-length'] || '0', 10);
// Allow empty-body requests (e.g. logout), multipart for uploads, and JSON for API calls
if (contentLength > 0 && !contentType.includes('application/json') && !contentType.includes('multipart/form-data')) {
return res.status(415).json({ error: 'Unsupported Content-Type. Use application/json or multipart/form-data.' });
}
}
next();
});
// Request logging for API routes (with timestamps)
const apiRequestLogger = (req, res, next) => {
try {
@@ -487,12 +512,14 @@ app.use('/api/auth', authRoutes);
// Gallery routes - main routes first, then feedback routes
app.use('/api/gallery', galleryRoutes);
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
app.use('/api/gallery', require('./src/routes/galleryGuests'));
app.use('/api/admin', adminRoutes);
app.use('/api/admin/auth', adminAuthRoutes);
app.use('/api/admin/system', require('./src/routes/adminSystem'));
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
app.use('/api/admin', require('./src/routes/adminGuests'));
app.use('/api/admin/image-security', require('./src/routes/adminImageSecurity'));
app.use('/api/admin/thumbnails', require('./src/routes/adminThumbnails'));
app.use('/api/admin/photos', require('./src/routes/adminPhotoDimensions'));
+12 -1
View File
@@ -3,9 +3,20 @@ const { db } = require('../database/db');
const logger = require('../utils/logger');
/**
* Generate a unique identifier for the guest
* Generate a unique identifier for the guest.
*
* In guest identity mode, `req.guest.identifier` is a server-issued UUID
* unique per person per event (set by the resolveGuest middleware). When
* present it takes precedence, so rate limits and deduplication become
* per-person instead of per-device.
*
* In simple (legacy) mode, the identifier falls back to a hash of IP + UA,
* matching prior behavior.
*/
function generateGuestIdentifier(req) {
if (req.guest && req.guest.identifier) {
return req.guest.identifier;
}
const ip = req.ip || req.connection.remoteAddress || 'unknown';
const userAgent = req.headers['user-agent'] || 'unknown';
return crypto
+41 -18
View File
@@ -4,6 +4,18 @@ const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
// Check if the request carries a valid admin preview token (Feature 3)
function isAdminPreview(req) {
const previewToken = req.query?.preview;
if (!previewToken) return false;
try {
const decoded = jwt.verify(previewToken, process.env.JWT_SECRET, { issuer: 'picpeak-auth' });
return decoded.type === 'admin';
} catch {
return false;
}
}
// Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) {
try {
@@ -16,15 +28,18 @@ async function verifyGalleryAccess(req, res, next) {
return res.status(401).json({ error: 'No token provided' });
}
const adminPreview = isAdminPreview(req);
event = await withRetry(async () => {
return await db('events')
.where({
const q = db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.select('*')
.first();
});
if (!adminPreview) {
q.where({ is_draft: formatBoolean(false) });
}
return await q.select('*').first();
});
if (!event) {
@@ -66,15 +81,18 @@ async function verifyGalleryAccess(req, res, next) {
// If we have a slug in the URL params or from pre-middleware, verify it matches
if (requestedSlug) {
// Verify by slug and ensure it matches the token's event
const adminPreviewToken = isAdminPreview(req);
event = await withRetry(async () => {
return await db('events')
.where({
const q = db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.select('*')
.first();
});
if (!adminPreviewToken) {
q.where({ is_draft: formatBoolean(false) });
}
return await q.select('*').first();
});
// Verify the token's eventId matches
@@ -83,15 +101,18 @@ async function verifyGalleryAccess(req, res, next) {
}
} else {
// Fallback to using eventId from token
const adminPreviewFallback = isAdminPreview(req);
event = await withRetry(async () => {
return await db('events')
.where({
id: decoded.eventId,
const q = db('events')
.where({
id: decoded.eventId,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.select('*')
.first();
});
if (!adminPreviewFallback) {
q.where({ is_draft: formatBoolean(false) });
}
return await q.select('*').first();
});
}
@@ -102,8 +123,9 @@ async function verifyGalleryAccess(req, res, next) {
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
req.event = event;
req.accessLevel = decoded.accessLevel || 'guest';
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
// Create client info for logging (similar to secureImageMiddleware but simpler)
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
@@ -121,5 +143,6 @@ async function verifyGalleryAccess(req, res, next) {
}
module.exports = {
verifyGalleryAccess
verifyGalleryAccess,
isAdminPreview
};
+105
View File
@@ -0,0 +1,105 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getGuestTokenFromRequest } = require('../utils/tokenUtils');
/**
* Non-blocking middleware. Reads an optional guest token from the request and,
* if present and valid, populates req.guest with { id, identifier, name, eventId }.
*
* If the token is missing, malformed, or expired → req.guest = null and the
* request continues. Downstream handlers (e.g. feedback submission) enforce
* presence explicitly based on event feedback settings (identity_mode).
*/
async function resolveGuest(req, res, next) {
try {
const slug = req.params?.slug;
const token = getGuestTokenFromRequest(req, slug);
if (!token) {
req.guest = null;
return next();
}
let decoded;
try {
const verified = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
complete: true,
});
decoded = verified.payload;
} catch (err) {
// Invalid or expired guest tokens are silently ignored so that public
// gallery browsing continues to work even if the token is stale.
logger.debug('Invalid guest token', { reason: err.message });
req.guest = null;
return next();
}
if (decoded.type !== 'guest') {
req.guest = null;
return next();
}
// Verify the guest row still exists and is not soft-deleted.
const guest = await db('gallery_guests')
.where({ id: decoded.guestId, event_id: decoded.eventId, is_deleted: false })
.first();
if (!guest) {
req.guest = null;
return next();
}
req.guest = {
id: guest.id,
eventId: guest.event_id,
identifier: guest.identifier,
name: guest.name,
email: guest.email || null,
};
return next();
} catch (error) {
logger.error('resolveGuest middleware error', { error: error.message });
req.guest = null;
return next();
}
}
/**
* Blocking middleware that 401s if no guest identity was resolved.
* Use this on endpoints that require a valid guest session.
*/
function requireGuest(req, res, next) {
if (!req.guest) {
return res.status(401).json({ error: 'Guest identity required' });
}
return next();
}
/**
* Sign a new guest JWT. Scoped to a specific event and guest row.
* Expiry matches the gallery token default (24h).
*/
function signGuestToken({ guestId, eventId, identifier, name }, expiresIn = '24h') {
return jwt.sign(
{
type: 'guest',
guestId,
eventId,
identifier,
name,
},
process.env.JWT_SECRET,
{
issuer: 'picpeak-auth',
expiresIn,
}
);
}
module.exports = {
resolveGuest,
requireGuest,
signGuestToken,
};
+35
View File
@@ -0,0 +1,35 @@
const { db } = require('../database/db');
/**
* Middleware to enforce event ownership for non-super_admin users.
* Super admins bypass the check. Other admins can only access events they created.
*/
function requireEventOwnership(req, res, next) {
if (req.admin.roleName === 'super_admin') {
return next();
}
const eventId = req.params.eventId || req.params.id;
if (!eventId) {
return res.status(400).json({ error: 'Event ID is required' });
}
db('events')
.where('id', eventId)
.first()
.then((event) => {
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Allow access if: event has no owner (legacy/system), or admin owns it
if (event.created_by && event.created_by !== req.admin.id) {
return res.status(403).json({ error: 'Access denied' });
}
next();
})
.catch((err) => {
res.status(500).json({ error: 'Failed to verify ownership' });
});
}
module.exports = { requireEventOwnership };
+16 -6
View File
@@ -85,18 +85,28 @@ async function sessionTimeoutMiddleware(req, res, next) {
const now = Date.now();
const lastActivity = sessions.get(token);
const timeout = await getSessionTimeout();
// If session exists, check if it's expired
if (lastActivity) {
// Existing session — check if idle too long
if (now - lastActivity > timeout) {
sessions.delete(token);
return res.status(401).json({
error: 'Session expired',
code: 'SESSION_TIMEOUT'
return res.status(401).json({
error: 'Session expired',
code: 'SESSION_TIMEOUT'
});
}
} else {
// First request with this token — check if token was issued longer ago than the timeout
// This prevents old/stolen tokens from bypassing session timeout after server restart
const tokenIssuedAt = (decoded.iat || 0) * 1000; // iat is in seconds
if (now - tokenIssuedAt > timeout) {
return res.status(401).json({
error: 'Session expired',
code: 'SESSION_TIMEOUT'
});
}
}
// Update last activity
sessions.set(token, now);
+5 -4
View File
@@ -7,6 +7,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const archiver = require('archiver');
const AdmZip = require('adm-zip');
const { requireEventOwnership } = require('../middleware/ownership');
const router = express.Router();
// Get all archived events
@@ -82,7 +83,7 @@ router.get('/', adminAuth, requirePermission('archives.view'), async (req, res)
});
// Get single archive details
router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, res) => {
router.get('/:id', adminAuth, requirePermission('archives.view'), requireEventOwnership, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -138,7 +139,7 @@ router.get('/:id', adminAuth, requirePermission('archives.view'), async (req, re
});
// Restore archive
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), async (req, res) => {
router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), requireEventOwnership, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -301,7 +302,7 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), as
});
// Download archive
router.get('/:id/download', adminAuth, requirePermission('archives.download'), async (req, res) => {
router.get('/:id/download', adminAuth, requirePermission('archives.download'), requireEventOwnership, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
@@ -350,7 +351,7 @@ router.get('/:id/download', adminAuth, requirePermission('archives.download'), a
});
// Delete archive permanently
router.delete('/:id', adminAuth, requirePermission('archives.delete'), async (req, res) => {
router.delete('/:id', adminAuth, requirePermission('archives.delete'), requireEventOwnership, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
+25 -2
View File
@@ -1,5 +1,6 @@
const express = require('express');
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
@@ -7,6 +8,7 @@ const { endSession } = require('../middleware/sessionTimeout');
const { validatePasswordStrength } = require('../utils/passwordGenerator');
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
const { setAdminAuthCookie } = require('../utils/tokenUtils');
const router = express.Router();
// Get admin profile
@@ -122,15 +124,36 @@ router.post('/change-password', [
// Hash new password with more rounds
const newPasswordHash = await bcrypt.hash(newPassword, 12);
// Update password and clear must_change_password flag
// Update password, set password_changed_at to invalidate existing tokens, and clear must_change_password flag
const now = new Date();
await db('admin_users')
.where('id', userId)
.update({
password_hash: newPasswordHash,
password_changed_at: now,
must_change_password: false,
updated_at: new Date()
updated_at: now
});
// Issue a new token so the session remains valid after password_changed_at invalidated the old one.
// Set iat to 1 second after password_changed_at to guarantee the token passes the
// "iat < password_changed_at" check in auth middleware (password_changed_at has ms precision
// but JWT iat is floored to seconds, which can cause the new token to be rejected).
const iatAfterPasswordChange = Math.floor(now.getTime() / 1000) + 1;
const newToken = jwt.sign({
id: user.id,
username: user.username,
type: 'admin',
role: user.role_name,
iat: iatAfterPasswordChange,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setAdminAuthCookie(res, newToken);
// Log activity
await logActivity('password_changed',
{ admin_id: userId },
+30 -9
View File
@@ -258,6 +258,13 @@ router.post('/test-connection', adminAuth, requirePermission('backup.create'), a
break;
}
// SSRF protection: block connections to private/internal addresses
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(host)) {
res.json({ success: false, message: 'Host cannot be a private or internal network address' });
break;
}
// Validate username format if provided
if (user && !/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
res.json({ success: false, message: 'Invalid username format' });
@@ -355,12 +362,17 @@ router.get('/manifest/:backupRunId', adminAuth, requirePermission('backup.view')
router.post('/manifest/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { manifestPath } = req.body;
if (!manifestPath) {
return res.status(400).json({ error: 'manifestPath is required' });
}
const result = await validateBackupManifest(manifestPath);
// Prevent path traversal — manifest must be within backup directory
const backupBasePath = process.env.BACKUP_PATH || path.join(__dirname, '../../../backups');
const { safePathJoin } = require('../utils/fileSecurityUtils');
const safePath = safePathJoin(backupBasePath, manifestPath);
const result = await validateBackupManifest(safePath);
res.json({
valid: result.valid,
@@ -456,19 +468,24 @@ router.get('/manifests/:backupId/download', adminAuth, requirePermission('backup
router.post('/manifests/validate', adminAuth, requirePermission('backup.view'), async (req, res) => {
try {
const { manifestPath, manifestData } = req.body;
if (!manifestPath && !manifestData) {
return res.status(400).json({ error: 'Either manifestPath or manifestData is required' });
}
if (manifestData) {
// Validate provided manifest data directly
const validationResult = await validateManifestData(manifestData);
return res.json(validationResult);
}
// Prevent path traversal — manifest must be within backup directory
const backupBasePath = process.env.BACKUP_PATH || path.join(__dirname, '../../../backups');
const { safePathJoin } = require('../utils/fileSecurityUtils');
const safePath = safePathJoin(backupBasePath, manifestPath);
// Use existing validation function for path
const result = await validateBackupManifest(manifestPath);
const result = await validateBackupManifest(safePath);
res.json({
valid: result.valid,
@@ -757,10 +774,14 @@ router.get('/checksums', adminAuth, requirePermission('backup.view'), async (req
try {
const { path: targetPath = '', recursive = true } = req.query;
const checksums = {};
// Get storage path
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const basePath = targetPath ? path.join(storagePath, targetPath) : storagePath;
let basePath = storagePath;
if (targetPath) {
const { safePathJoin } = require('../utils/fileSecurityUtils');
basePath = safePathJoin(storagePath, targetPath);
}
// Calculate checksums for files
async function calculateDirChecksums(dirPath, relative = '') {
+198 -148
View File
@@ -4,6 +4,7 @@ const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { wrapEmailHtml } = require('../services/emailProcessor');
const router = express.Router();
// Get email configuration
@@ -60,6 +61,12 @@ router.post('/config', [
tls_reject_unauthorized
} = req.body;
// Validate SMTP host is not a private/internal address (SSRF protection)
const { isPrivateIP } = require('../utils/networkValidation');
if (isPrivateIP(smtp_host)) {
return res.status(400).json({ error: 'SMTP host cannot point to a private or internal network address' });
}
// Check if config exists
const existingConfig = await db('email_configs').first();
@@ -159,22 +166,26 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
const transporter = nodemailer.createTransport(transportConfig);
// Send test email
// Send test email with the same wrapper used for all other emails
const subject = 'Test Email - Photo Sharing Platform';
const testHtmlBody = `
<h2>Test Email Successful!</h2>
<p>This is a test email from your Photo Sharing platform.</p>
<p>If you're seeing this, your email configuration is working correctly.</p>
<hr>
<p style="color: #666; font-size: 12px;">
Sent from: ${config.from_email}<br>
SMTP Host: ${config.smtp_host}<br>
Time: ${new Date().toISOString()}
</p>
`;
const wrappedHtml = await wrapEmailHtml(testHtmlBody, subject);
await transporter.sendMail({
from: `${config.from_name} <${config.from_email}>`,
to: test_email,
subject: 'Test Email - Photo Sharing Platform',
html: `
<h2>Test Email Successful!</h2>
<p>This is a test email from your Photo Sharing platform.</p>
<p>If you're seeing this, your email configuration is working correctly.</p>
<hr>
<p style="color: #666; font-size: 12px;">
Sent from: ${config.from_email}<br>
SMTP Host: ${config.smtp_host}<br>
Time: ${new Date().toISOString()}
</p>
`,
subject,
html: wrappedHtml,
text: 'Test Email Successful! Your email configuration is working correctly.'
});
@@ -237,6 +248,58 @@ router.post('/test', adminAuth, requirePermission('email.send'), async (req, res
}
});
// Helper: parse variables JSON safely
function parseVariables(template) {
try {
if (!template.variables) return [];
if (typeof template.variables === 'object') return template.variables;
return JSON.parse(template.variables);
} catch (e) {
console.warn('Failed to parse variables for template:', template.template_key, e.message);
return [];
}
}
// Helper: get translations for a template, with legacy column fallback
async function getTemplateTranslations(templateId, template) {
const translations = {};
try {
const rows = await db('email_template_translations')
.where('template_id', templateId)
.select('language', 'subject', 'body_html', 'body_text');
rows.forEach(row => {
translations[row.language] = {
subject: row.subject || '',
body_html: row.body_html || '',
body_text: row.body_text || '',
};
});
} catch (error) {
// Translations table might not exist yet (pre-migration)
// Fall back to legacy columns
if (template.subject_en !== undefined) {
translations.en = {
subject: template.subject_en || '',
body_html: template.body_html_en || '',
body_text: template.body_text_en || '',
};
translations.de = {
subject: template.subject_de || '',
body_html: template.body_html_de || '',
body_text: template.body_text_de || '',
};
} else {
translations.en = {
subject: template.subject || '',
body_html: template.body_html || '',
body_text: template.body_text || '',
};
}
}
return translations;
}
// Get email templates
router.get('/templates', adminAuth, requirePermission('email.view'), async (req, res) => {
try {
@@ -244,45 +307,17 @@ router.get('/templates', adminAuth, requirePermission('email.view'), async (req,
.select('*')
.orderBy('template_key');
// Parse variables JSON and format for multi-language support
const formattedTemplates = templates.map(template => {
const result = {
const formattedTemplates = [];
for (const template of templates) {
const translations = await getTemplateTranslations(template.id, template);
formattedTemplates.push({
id: template.id,
template_key: template.template_key,
variables: (() => {
try {
if (!template.variables) return [];
if (typeof template.variables === 'object') return template.variables;
return JSON.parse(template.variables);
} catch (e) {
console.warn('Failed to parse variables for template:', template.template_key, e.message);
return [];
}
})(),
updated_at: template.updated_at
};
// Handle both old and new schema formats
if (template.subject_en !== undefined) {
// New schema with language columns
result.subject_en = template.subject_en;
result.body_html_en = template.body_html_en;
result.body_text_en = template.body_text_en;
result.subject_de = template.subject_de;
result.body_html_de = template.body_html_de;
result.body_text_de = template.body_text_de;
} else {
// Old schema - use basic columns for both languages
result.subject_en = template.subject;
result.body_html_en = template.body_html;
result.body_text_en = template.body_text;
result.subject_de = template.subject;
result.body_html_de = template.body_html;
result.body_text_de = template.body_text;
}
return result;
});
variables: parseVariables(template),
translations,
updated_at: template.updated_at,
});
}
res.json(formattedTemplates);
} catch (error) {
@@ -302,119 +337,99 @@ router.get('/templates/:key', adminAuth, requirePermission('email.view'), async
return res.status(404).json({ error: 'Template not found' });
}
// Handle both old and new schema formats
const response = {
const translations = await getTemplateTranslations(template.id, template);
res.json({
id: template.id,
template_key: template.template_key,
variables: (() => {
try {
if (!template.variables) return [];
if (typeof template.variables === 'object') return template.variables;
return JSON.parse(template.variables);
} catch (e) {
console.warn('Failed to parse variables for template:', template.template_key, e.message);
return [];
}
})(),
updated_at: template.updated_at
};
// Check which columns exist and use them appropriately
if (template.subject_en !== undefined) {
// New schema with language columns
response.subject_en = template.subject_en;
response.body_html_en = template.body_html_en;
response.body_text_en = template.body_text_en;
response.subject_de = template.subject_de;
response.body_html_de = template.body_html_de;
response.body_text_de = template.body_text_de;
} else {
// Old schema - use basic columns for both languages
response.subject_en = template.subject;
response.body_html_en = template.body_html;
response.body_text_en = template.body_text;
response.subject_de = template.subject;
response.body_html_de = template.body_html;
response.body_text_de = template.body_text;
}
res.json(response);
variables: parseVariables(template),
translations,
updated_at: template.updated_at,
});
} catch (error) {
console.error('Email template fetch error:', error);
res.status(500).json({ error: 'Failed to fetch email template' });
}
});
// Update email template
// Update email template translations
router.put('/templates/:key', [
adminAuth,
requirePermission('email.edit'),
body('subject_en').optional().notEmpty().withMessage('English subject cannot be empty'),
body('subject_de').optional().notEmpty().withMessage('German subject cannot be empty'),
body('body_html_en').optional().notEmpty().withMessage('English HTML body cannot be empty'),
body('body_html_de').optional().notEmpty().withMessage('German HTML body cannot be empty')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
subject_en, subject_de,
body_html_en, body_html_de,
body_text_en, body_text_de
} = req.body;
const updateData = {
updated_at: new Date()
};
// Check which columns exist in the database
const template = await db('email_templates')
.where('template_key', req.params.key)
.first();
if (!template) {
return res.status(404).json({ error: 'Template not found' });
}
// Determine schema type and update accordingly
if (template.subject_en !== undefined) {
// New schema with language columns
if (subject_en !== undefined) updateData.subject_en = subject_en;
if (subject_de !== undefined) updateData.subject_de = subject_de;
if (body_html_en !== undefined) updateData.body_html_en = body_html_en;
if (body_html_de !== undefined) updateData.body_html_de = body_html_de;
if (body_text_en !== undefined) updateData.body_text_en = body_text_en || '';
if (body_text_de !== undefined) updateData.body_text_de = body_text_de || '';
// Also update basic columns if they exist
if (template.subject !== undefined) {
updateData.subject = subject_en || updateData.subject_en;
updateData.body_html = body_html_en || updateData.body_html_en;
updateData.body_text = body_text_en || updateData.body_text_en || '';
}
} else {
// Old schema - only update basic columns
if (subject_en !== undefined) {
updateData.subject = subject_en;
updateData.body_html = body_html_en;
updateData.body_text = body_text_en || '';
const { translations } = req.body;
if (!translations || typeof translations !== 'object') {
return res.status(400).json({ error: 'translations object is required' });
}
// Upsert each language translation
for (const [language, data] of Object.entries(translations)) {
if (!data || typeof data !== 'object') continue;
const existing = await db('email_template_translations')
.where({ template_id: template.id, language })
.first();
const row = {
subject: data.subject || '',
body_html: data.body_html || '',
body_text: data.body_text || '',
updated_at: new Date(),
};
if (existing) {
await db('email_template_translations')
.where({ template_id: template.id, language })
.update(row);
} else {
await db('email_template_translations').insert({
template_id: template.id,
language,
...row,
created_at: new Date(),
});
}
}
const updated = await db('email_templates')
.where('template_key', req.params.key)
.update(updateData);
// Update timestamp on parent template
await db('email_templates')
.where('id', template.id)
.update({ updated_at: new Date() });
if (!updated) {
return res.status(404).json({ error: 'Template not found' });
// Also sync legacy columns for backward compatibility
const enData = translations.en;
const deData = translations.de;
const legacyUpdate = { updated_at: new Date() };
const columnInfo = await db('email_templates').columnInfo();
if (enData && columnInfo.subject_en) {
legacyUpdate.subject_en = enData.subject || '';
legacyUpdate.body_html_en = enData.body_html || '';
legacyUpdate.body_text_en = enData.body_text || '';
}
if (deData && columnInfo.subject_de) {
legacyUpdate.subject_de = deData.subject || '';
legacyUpdate.body_html_de = deData.body_html || '';
legacyUpdate.body_text_de = deData.body_text || '';
}
await db('email_templates')
.where('id', template.id)
.update(legacyUpdate);
// Log activity
await logActivity('email_template_updated',
{ template_key: req.params.key },
{ template_key: req.params.key, languages: Object.keys(translations) },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
@@ -438,29 +453,64 @@ router.post('/templates/:key/preview', adminAuth, requirePermission('email.view'
}
const { preview_data, language = 'en' } = req.body;
// Get the appropriate language version
const subjectField = language === 'de' && template.subject_de ? 'subject_de' : 'subject_en';
const htmlField = language === 'de' && template.body_html_de ? 'body_html_de' : 'body_html_en';
const textField = language === 'de' && template.body_text_de ? 'body_text_de' : 'body_text_en';
// Handle backward compatibility
let htmlContent = template[htmlField] || template.body_html || '';
let textContent = template[textField] || template.body_text || '';
let subject = template[subjectField] || template.subject || '';
// Get translation from translations table with fallback
let translation = null;
try {
translation = await db('email_template_translations')
.where({ template_id: template.id, language })
.first();
if (!translation && language !== 'en') {
translation = await db('email_template_translations')
.where({ template_id: template.id, language: 'en' })
.first();
}
} catch (e) {
// Fallback to legacy columns
}
let subject = '';
let htmlContent = '';
let textContent = '';
if (translation) {
subject = translation.subject || '';
htmlContent = translation.body_html || '';
textContent = translation.body_text || '';
} else {
// Legacy column fallback
const subjectField = language === 'de' && template.subject_de ? 'subject_de' : 'subject_en';
const htmlField = language === 'de' && template.body_html_de ? 'body_html_de' : 'body_html_en';
const textField = language === 'de' && template.body_text_de ? 'body_text_de' : 'body_text_en';
subject = template[subjectField] || template.subject || '';
htmlContent = template[htmlField] || template.body_html || '';
textContent = template[textField] || template.body_text || '';
}
if (preview_data) {
const escapeHtml = (str) => String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
Object.keys(preview_data).forEach(key => {
const regex = new RegExp(`{{${key}}}`, 'g');
htmlContent = htmlContent.replace(regex, preview_data[key]);
const escapedValue = escapeHtml(preview_data[key]);
htmlContent = htmlContent.replace(regex, escapedValue);
textContent = textContent.replace(regex, preview_data[key]);
subject = subject.replace(regex, preview_data[key]);
subject = subject.replace(regex, escapeHtml(preview_data[key]));
});
}
// Wrap in the full styled email template with header/footer/logo
const wrappedHtml = await wrapEmailHtml(htmlContent, subject, language);
res.json({
subject,
body_html: htmlContent,
body_html: wrappedHtml,
body_text: textContent,
language
});
+4 -2
View File
@@ -42,7 +42,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
color_theme = null,
expiration_days = 30,
allow_user_uploads = false,
upload_category_id = null
upload_category_id = null,
photo_cap = null
} = req.body;
// Validate password strength for gallery
@@ -105,7 +106,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(),
allow_user_uploads,
upload_category_id
upload_category_id,
photo_cap: photo_cap || null
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
+211 -28
View File
@@ -20,6 +20,8 @@ const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
const { validateFileType } = require('../utils/fileSecurityUtils');
const { requireEventOwnership } = require('../middleware/ownership');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
// Shared validator for hero_image_anchor accepts legacy keywords or "X% Y%" focal point
const validateHeroImageAnchor = (value) => {
@@ -110,6 +112,50 @@ const getEventFieldRequirements = async () => {
}
};
// Helper to get branding defaults for new events (Feature 7: Branding Inheritance)
const getBrandingDefaults = async () => {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'branding_logo_display_hero',
'branding_logo_size',
'branding_logo_position'
])
.select('setting_key', 'setting_value');
const defaults = {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
settings.forEach(s => {
let value = s.setting_value;
if (typeof value === 'string') {
try { value = JSON.parse(value); } catch (e) { /* use as-is */ }
}
if (s.setting_key === 'branding_logo_display_hero') {
defaults.hero_logo_visible = value !== false;
}
if (s.setting_key === 'branding_logo_size' && value) {
defaults.hero_logo_size = value;
}
if (s.setting_key === 'branding_logo_position' && value) {
defaults.hero_logo_position = value;
}
});
return defaults;
} catch (error) {
logger.error('Failed to get branding defaults', { error: error.message });
return {
hero_logo_visible: true,
hero_logo_size: 'medium',
hero_logo_position: 'top'
};
}
};
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
const getCustomerEmailFromPayload = (payload = {}) => parseStringInput(payload.customer_email);
@@ -124,6 +170,8 @@ const mapEventForApi = (event) => {
host_email,
customer_name,
customer_email,
password_hash: _ph,
client_password_hash: _cph,
...rest
} = event;
@@ -208,7 +256,15 @@ router.post('/', adminAuth, requirePermission('events.create'), [
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
// Hero image anchor position (#162) accepts legacy keywords or "X% Y%" focal point
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
// Client access settings (#172)
body('client_access_enabled').optional().isBoolean(),
body('client_password').optional().isString(),
body('default_photo_sort').optional().isIn([
'upload_date_desc', 'upload_date_asc',
'capture_date_desc', 'capture_date_asc',
'filename_asc', 'filename_desc'
])
], async (req, res) => {
try {
logger.debug('Create event request body', { body: req.body });
@@ -256,7 +312,16 @@ router.post('/', adminAuth, requirePermission('events.create'), [
header_style = 'standard',
hero_divider_style = 'wave',
// Hero image anchor position (#162)
hero_image_anchor = 'center'
hero_image_anchor = 'center',
// Photo cap
photo_cap = null,
// Client access settings (#172)
client_access_enabled = false,
client_password = null,
// Draft mode
is_draft = true,
// Default photo sort
default_photo_sort = 'upload_date_desc'
} = req.body;
const customerName = getCustomerNameFromPayload(req.body);
@@ -385,6 +450,12 @@ router.post('/', adminAuth, requirePermission('events.create'), [
}
}
// Get branding defaults for hero logo settings (Feature 7: Branding Inheritance)
const brandingDefaults = await getBrandingDefaults();
const effectiveHeroLogoVisible = req.body.hero_logo_visible !== undefined ? hero_logo_visible : brandingDefaults.hero_logo_visible;
const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size;
const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position;
// Insert into database
const insertResult = await db('events').insert({
slug,
@@ -411,12 +482,21 @@ router.post('/', adminAuth, requirePermission('events.create'), [
watermark_text,
require_password: formatBoolean(requirePassword),
css_template_id: css_template_id || null,
hero_logo_visible: formatBoolean(hero_logo_visible !== undefined ? hero_logo_visible : true),
hero_logo_size: hero_logo_size || 'medium',
hero_logo_position: hero_logo_position || 'top',
hero_logo_visible: formatBoolean(effectiveHeroLogoVisible),
hero_logo_size: effectiveHeroLogoSize,
hero_logo_position: effectiveHeroLogoPosition,
header_style: effectiveHeaderStyle || 'standard',
hero_divider_style: effectiveDividerStyle || 'wave',
hero_image_anchor: hero_image_anchor || 'center'
hero_image_anchor: hero_image_anchor || 'center',
photo_cap: photo_cap || null,
is_draft: formatBoolean(parseBooleanInput(is_draft, true)),
default_photo_sort: default_photo_sort || 'upload_date_desc',
// Client access (#172)
client_access_enabled: formatBoolean(client_access_enabled),
...(client_access_enabled && client_password ? {
client_password_hash: await bcrypt.hash(client_password, getBcryptRounds()),
client_share_token: crypto.randomBytes(32).toString('hex')
} : {})
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
@@ -446,25 +526,37 @@ router.post('/', adminAuth, requirePermission('events.create'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Queue creation email (only if there is a recipient)
// Queue creation email (only if there is a recipient and event is not a draft)
// Language detection is handled by email processor
const isDraft = parseBooleanInput(is_draft, true);
if (customerEmail && !isDraft) {
// Build email data with optional client access info
const emailData = {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
};
// Include client access info in email when enabled (#172)
if (client_access_enabled && client_password) {
const createdEvent = await db('events').where('id', eventId).first();
const frontendUrl = process.env.FRONTEND_URL || process.env.APP_URL || '';
emailData.client_link = `${frontendUrl}/gallery/${slug}/client-access?token=${createdEvent.client_share_token}`;
emailData.client_password = client_password;
}
if (customerEmail) {
await db('email_queue').insert({
event_id: eventId,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify({
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
}),
email_data: JSON.stringify(emailData),
status: 'pending',
created_at: new Date()
// scheduled_at will use default value
@@ -479,6 +571,8 @@ router.post('/', adminAuth, requirePermission('events.create'), [
customer_name: customerName,
customer_email: customerEmail,
require_password: requirePassword,
photo_cap: photo_cap || null,
is_draft: isDraft,
share_link: shareUrl,
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString()
@@ -526,6 +620,8 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) =>
query = query.where('is_archived', formatBoolean(true));
} else if (status === 'inactive') {
query = query.where('is_active', formatBoolean(false)).where('is_archived', formatBoolean(false));
} else if (status === 'draft') {
query = query.where('is_draft', formatBoolean(true));
} else if (status === 'expiring') {
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
@@ -650,8 +746,67 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res)
}
});
// Publish a draft event (set is_draft=false and queue creation email)
router.post('/:id/publish', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (!parseBooleanInput(event.is_draft, false)) {
return res.status(400).json({ error: 'Event is already published' });
}
// Set is_draft to false
await db('events').where('id', id).update({ is_draft: formatBoolean(false) });
// Queue creation email
const customerEmail = event.customer_email || event.host_email;
const customerName = event.customer_name || event.host_name;
if (customerEmail) {
const frontendBase = await getFrontendBaseUrl();
const { shareUrl } = await buildShareLinkVariants({ slug: event.slug, shareToken: event.share_token });
const emailData = {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name: event.event_name,
event_date: event.event_date,
gallery_link: shareUrl || `${frontendBase}/gallery/${event.slug}`,
gallery_password: parseBooleanInput(event.require_password, true) ? '(set at creation)' : 'No password required',
expiry_date: event.expires_at ? new Date(event.expires_at).toISOString() : null,
welcome_message: event.welcome_message || ''
};
await db('email_queue').insert({
event_id: id,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify(emailData),
status: 'pending',
created_at: new Date()
});
}
await logActivity('event_published',
{ event_name: event.event_name },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Event published successfully', is_draft: false });
} catch (error) {
logger.error('Error publishing event:', { error: error.message });
res.status(500).json({ error: 'Failed to publish event' });
}
});
// Update event
router.put('/:id', adminAuth, requirePermission('events.edit'), [
router.put('/:id', adminAuth, requirePermission('events.edit'), requireEventOwnership, [
body('event_name').optional().trim().notEmpty(),
body('admin_email').optional().isEmail(),
body('is_active').optional().isBoolean(),
@@ -705,7 +860,16 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('header_style').optional().isIn(['hero', 'standard', 'minimal', 'none']),
body('hero_divider_style').optional().isIn(['wave', 'straight', 'angle', 'curve', 'none']),
// Hero image anchor position (#162) accepts legacy keywords or "X% Y%" focal point
body('hero_image_anchor').optional().custom(validateHeroImageAnchor)
body('hero_image_anchor').optional().custom(validateHeroImageAnchor),
// Client access settings (#172)
body('client_access_enabled').optional().isBoolean(),
body('client_password').optional().isString(),
body('regenerate_client_token').optional().isBoolean(),
body('default_photo_sort').optional().isIn([
'upload_date_desc', 'upload_date_asc',
'capture_date_desc', 'capture_date_asc',
'filename_asc', 'filename_desc'
])
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -784,6 +948,25 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
}
// Handle client access fields (#172)
if (Object.prototype.hasOwnProperty.call(updates, 'client_access_enabled')) {
updates.client_access_enabled = formatBoolean(updates.client_access_enabled);
// Auto-generate client share token when first enabling
if (parseBooleanInput(updates.client_access_enabled, false) && !event.client_share_token) {
updates.client_share_token = crypto.randomBytes(32).toString('hex');
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'client_password') && updates.client_password) {
updates.client_password_hash = await bcrypt.hash(updates.client_password, getBcryptRounds());
delete updates.client_password;
} else {
delete updates.client_password;
}
if (updates.regenerate_client_token) {
updates.client_share_token = crypto.randomBytes(32).toString('hex');
}
delete updates.regenerate_client_token;
// Log the update request for debugging
logger.debug('Update event request', {
id,
@@ -874,7 +1057,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
});
// Delete event
router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req, res) => {
router.delete('/:id', adminAuth, requirePermission('events.delete'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
@@ -965,7 +1148,7 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req,
});
// Toggle event status
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), async (req, res) => {
router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
@@ -1005,7 +1188,7 @@ router.post('/:id/toggle-status', adminAuth, requirePermission('events.edit'), a
});
// Reset event password
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), async (req, res) => {
router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
const { sendEmail = true } = req.body;
@@ -1072,7 +1255,7 @@ router.post('/:id/reset-password', adminAuth, requirePermission('events.edit'),
});
// Resend creation email
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), async (req, res) => {
router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
@@ -1156,7 +1339,7 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), as
});
// Archive event
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), async (req, res) => {
router.post('/:id/archive', adminAuth, requirePermission('events.archive'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
@@ -1266,7 +1449,7 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
});
// Upload event custom logo
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), eventLogoUpload.single('logo'), async (req, res) => {
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => {
try {
const { id } = req.params;
@@ -1321,7 +1504,7 @@ router.post('/:id/logo', adminAuth, requirePermission('events.edit'), eventLogoU
});
// Delete event custom logo
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), async (req, res) => {
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
+15
View File
@@ -5,6 +5,7 @@ const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
const { db, logActivity } = require('../database/db');
const sharp = require('sharp');
const logger = require('../utils/logger');
const router = express.Router();
@@ -108,6 +109,18 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
.first();
if (exists) { skipped++; continue; }
const stats = await fs.stat(f.full);
// Extract dimensions via Sharp
let width = null;
let height = null;
try {
const metadata = await sharp(f.full).metadata();
width = metadata.width || null;
height = metadata.height || null;
} catch (dimErr) {
logger.warn(`Could not extract dimensions for ${f.rel}: ${dimErr.message}`);
}
const inserted = await db('photos')
.insert({
event_id: eventId,
@@ -117,6 +130,8 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
thumbnail_path: null,
type,
size_bytes: stats.size,
width,
height,
source_origin: 'external',
external_relpath: f.rel
})
+6
View File
@@ -12,11 +12,13 @@ const {
validateWordFilter,
checkValidation
} = require('../utils/feedbackValidation');
const { requireEventOwnership } = require('../middleware/ownership');
// Get event feedback settings
router.get('/events/:eventId/feedback-settings',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
validateEventId,
checkValidation,
async (req, res) => {
@@ -42,6 +44,7 @@ router.get('/events/:eventId/feedback-settings',
router.put('/events/:eventId/feedback-settings',
adminAuth,
requirePermission('events.edit'),
requireEventOwnership,
validateEventId,
validateFeedbackSettings,
checkValidation,
@@ -79,6 +82,7 @@ router.put('/events/:eventId/feedback-settings',
router.get('/events/:eventId/feedback',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
validateEventId,
checkValidation,
async (req, res) => {
@@ -204,6 +208,7 @@ router.delete('/feedback/:feedbackId',
router.get('/events/:eventId/feedback-analytics',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
validateEventId,
checkValidation,
async (req, res) => {
@@ -304,6 +309,7 @@ router.get('/events/:eventId/feedback-analytics',
router.get('/events/:eventId/feedback/export',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
validateEventId,
checkValidation,
async (req, res) => {
+609
View File
@@ -0,0 +1,609 @@
const express = require('express');
const crypto = require('crypto');
const archiver = require('archiver');
const router = express.Router();
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { requireEventOwnership } = require('../middleware/ownership');
const feedbackService = require('../services/feedbackService');
const logger = require('../utils/logger');
const FRONTEND_URL = process.env.FRONTEND_URL || '';
// ----------------------------------------------------------------------------
// Helpers
// ----------------------------------------------------------------------------
async function loadGuestOr404(eventId, guestId, res) {
const guest = await db('gallery_guests')
.where({ id: guestId, event_id: eventId, is_deleted: false })
.first();
if (!guest) {
res.status(404).json({ error: 'Guest not found' });
return null;
}
return guest;
}
function serializeGuest(row) {
return {
id: row.id,
name: row.name,
email: row.email,
created_at: row.created_at,
last_seen_at: row.last_seen_at,
email_verified_at: row.email_verified_at,
is_deleted: row.is_deleted,
};
}
function escapeCsvCell(value) {
const str = value == null ? '' : String(value);
if (/[,"\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests — list guests with aggregated counts
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId } = req.params;
const rows = await db('gallery_guests')
.leftJoin('photo_feedback', function () {
this.on('photo_feedback.guest_id', '=', 'gallery_guests.id');
})
.where('gallery_guests.event_id', eventId)
.where('gallery_guests.is_deleted', false)
.groupBy('gallery_guests.id')
.select(
'gallery_guests.id',
'gallery_guests.name',
'gallery_guests.email',
'gallery_guests.created_at',
'gallery_guests.last_seen_at',
'gallery_guests.email_verified_at',
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'like' THEN 1 END) AS likes"),
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'favorite' THEN 1 END) AS favorites"),
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'comment' THEN 1 END) AS comments"),
db.raw("COUNT(CASE WHEN photo_feedback.feedback_type = 'rating' THEN 1 END) AS ratings"),
db.raw('COUNT(DISTINCT photo_feedback.photo_id) AS distinct_photos')
)
.orderBy('gallery_guests.created_at', 'desc');
const guests = rows.map((r) => ({
...serializeGuest(r),
stats: {
likes: parseInt(r.likes, 10) || 0,
favorites: parseInt(r.favorites, 10) || 0,
comments: parseInt(r.comments, 10) || 0,
ratings: parseInt(r.ratings, 10) || 0,
distinct_photos: parseInt(r.distinct_photos, 10) || 0,
},
}));
res.json({ guests });
} catch (error) {
logger.error('Error listing guests:', error);
res.status(500).json({ error: 'Failed to list guests' });
}
}
);
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests/aggregate — photos sorted by distinct
// guest pick count (Phase 2 aggregate view)
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests/aggregate',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId } = req.params;
const photos = await db('photos')
.leftJoin('photo_feedback', function () {
this.on('photo_feedback.photo_id', '=', 'photos.id')
.andOn(db.raw("photo_feedback.feedback_type IN ('like','favorite')"))
.andOnNotNull('photo_feedback.guest_id');
})
.where('photos.event_id', eventId)
.groupBy('photos.id')
.select(
'photos.id',
'photos.filename',
'photos.original_filename',
db.raw('COUNT(DISTINCT photo_feedback.guest_id) AS picker_count')
)
.orderBy('picker_count', 'desc')
.orderBy('photos.id', 'desc');
res.json({
photos: photos
.filter((p) => parseInt(p.picker_count, 10) > 0)
.map((p) => ({
id: p.id,
filename: p.filename,
original_filename: p.original_filename,
url: `/api/admin/photos/${eventId}/photo/${p.id}`,
thumbnail_url: `/api/admin/photos/${eventId}/thumbnail/${p.id}`,
picker_count: parseInt(p.picker_count, 10),
})),
});
} catch (error) {
logger.error('Error fetching aggregate view:', error);
res.status(500).json({ error: 'Failed to fetch aggregate view' });
}
}
);
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests/invites — list pre-minted invites
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests/invites',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId } = req.params;
const event = await db('events').where({ id: eventId }).first();
const rows = await db('guest_invites')
.leftJoin('gallery_guests', 'gallery_guests.id', 'guest_invites.guest_id')
.where('guest_invites.event_id', eventId)
.select(
'guest_invites.id',
'guest_invites.token',
'guest_invites.created_at',
'guest_invites.redeemed_at',
'guest_invites.revoked_at',
'gallery_guests.id as guest_id',
'gallery_guests.name as guest_name',
'gallery_guests.email as guest_email'
)
.orderBy('guest_invites.created_at', 'desc');
const invites = rows.map((r) => ({
id: r.id,
token: r.token,
url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${r.token}`,
created_at: r.created_at,
redeemed_at: r.redeemed_at,
revoked_at: r.revoked_at,
status: r.revoked_at ? 'revoked' : r.redeemed_at ? 'redeemed' : 'pending',
guest: {
id: r.guest_id,
name: r.guest_name,
email: r.guest_email,
},
}));
res.json({ invites });
} catch (error) {
logger.error('Error listing invites:', error);
res.status(500).json({ error: 'Failed to list invites' });
}
}
);
// ----------------------------------------------------------------------------
// POST /admin/events/:eventId/guests/invites — create guest + invite
// Body: { name, email? }
// ----------------------------------------------------------------------------
router.post(
'/events/:eventId/guests/invites',
adminAuth,
requirePermission('events.edit'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId } = req.params;
const name = String(req.body?.name || '').trim().slice(0, 100);
const email = String(req.body?.email || '').trim().slice(0, 255).toLowerCase();
if (!name) {
return res.status(400).json({ error: 'Name is required' });
}
const identifier = crypto.randomUUID();
const inviteToken = crypto.randomBytes(24).toString('hex');
let guestId;
let inviteId;
await db.transaction(async (trx) => {
const [guestRow] = await trx('gallery_guests')
.insert({
event_id: eventId,
name,
email: email || null,
identifier,
})
.returning(['id']);
guestId = guestRow.id;
const [inviteRow] = await trx('guest_invites')
.insert({
event_id: eventId,
guest_id: guestId,
token: inviteToken,
created_by_admin_id: req.admin.id,
})
.returning(['id']);
inviteId = inviteRow.id;
});
await logActivity(
'guest_invite_created',
{ event_id: eventId, guest_id: guestId, invite_id: inviteId },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
const event = await db('events').where({ id: eventId }).first();
res.json({
invite: {
id: inviteId,
token: inviteToken,
url: `${FRONTEND_URL}/gallery/${event.slug}?invite=${inviteToken}`,
status: 'pending',
guest: { id: guestId, name, email: email || null },
},
});
} catch (error) {
logger.error('Error creating invite:', error);
res.status(500).json({ error: 'Failed to create invite' });
}
}
);
// ----------------------------------------------------------------------------
// DELETE /admin/events/:eventId/guests/invites/:inviteId — revoke
// ----------------------------------------------------------------------------
router.delete(
'/events/:eventId/guests/invites/:inviteId',
adminAuth,
requirePermission('events.edit'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId, inviteId } = req.params;
const updated = await db('guest_invites')
.where({ id: inviteId, event_id: eventId })
.whereNull('revoked_at')
.update({ revoked_at: db.fn.now() });
if (!updated) {
return res.status(404).json({ error: 'Invite not found or already revoked' });
}
await logActivity(
'guest_invite_revoked',
{ event_id: eventId, invite_id: inviteId },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ success: true });
} catch (error) {
logger.error('Error revoking invite:', error);
res.status(500).json({ error: 'Failed to revoke invite' });
}
}
);
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests/export-all — ZIP of per-guest exports
// Query: format=txt|csv|json (default: csv)
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests/export-all',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId } = req.params;
const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'csv';
const guests = await db('gallery_guests')
.where({ event_id: eventId, is_deleted: false })
.select('id', 'name', 'email');
if (guests.length === 0) {
return res.status(404).json({ error: 'No guests to export' });
}
res.setHeader('Content-Type', 'application/zip');
res.setHeader(
'Content-Disposition',
`attachment; filename="event-${eventId}-guests.zip"`
);
const archive = archiver('zip', { zlib: { level: 9 } });
archive.on('error', (err) => {
logger.error('Archive error:', err);
res.status(500).end();
});
archive.pipe(res);
for (const g of guests) {
const selections = await db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.guest_id', g.id)
.whereIn('photo_feedback.feedback_type', ['like', 'favorite'])
.select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type');
const safeName = g.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${g.id}`;
const filename = `${safeName}.${format}`;
let body;
if (format === 'json') {
body = JSON.stringify({ guest: g, selections }, null, 2);
} else if (format === 'csv') {
const header = 'filename,original_filename,feedback_type';
const rows = selections.map(
(s) =>
`${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}`
);
body = [header, ...rows].join('\n');
} else {
// txt — just filenames
body = selections.map((s) => s.original_filename || s.filename).join('\n');
}
archive.append(body, { name: filename });
}
await archive.finalize();
} catch (error) {
logger.error('Error exporting all guests:', error);
if (!res.headersSent) {
res.status(500).json({ error: 'Failed to export guests' });
}
}
}
);
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests/:guestId — guest detail with selections
// (Phase 2)
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests/:guestId',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId, guestId } = req.params;
const guest = await loadGuestOr404(eventId, guestId, res);
if (!guest) return;
const feedback = await db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.guest_id', guestId)
.select(
'photo_feedback.id as feedback_id',
'photo_feedback.feedback_type',
'photo_feedback.rating',
'photo_feedback.comment_text',
'photo_feedback.created_at',
'photos.id as photo_id',
'photos.filename',
'photos.original_filename',
'photos.type'
)
.orderBy('photo_feedback.created_at', 'desc');
const photoFor = (row) => ({
id: row.photo_id,
filename: row.filename,
original_filename: row.original_filename,
type: row.type,
url: `/api/admin/photos/${eventId}/photo/${row.photo_id}`,
thumbnail_url: `/api/admin/photos/${eventId}/thumbnail/${row.photo_id}`,
});
const selections = {
liked: [],
favorited: [],
rated: [],
commented: [],
};
for (const row of feedback) {
if (row.feedback_type === 'like') {
selections.liked.push(photoFor(row));
} else if (row.feedback_type === 'favorite') {
selections.favorited.push(photoFor(row));
} else if (row.feedback_type === 'rating') {
selections.rated.push({ photo: photoFor(row), rating: row.rating });
} else if (row.feedback_type === 'comment') {
selections.commented.push({
photo: photoFor(row),
comment: row.comment_text,
created_at: row.created_at,
});
}
}
res.json({
guest: {
...serializeGuest(guest),
stats: {
likes: selections.liked.length,
favorites: selections.favorited.length,
comments: selections.commented.length,
ratings: selections.rated.length,
},
},
selections,
});
} catch (error) {
logger.error('Error fetching guest detail:', error);
res.status(500).json({ error: 'Failed to fetch guest detail' });
}
}
);
// ----------------------------------------------------------------------------
// GET /admin/events/:eventId/guests/:guestId/export — per-guest export
// Query: format=txt|csv|json
// ----------------------------------------------------------------------------
router.get(
'/events/:eventId/guests/:guestId/export',
adminAuth,
requirePermission('events.view'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId, guestId } = req.params;
const format = ['txt', 'csv', 'json'].includes(req.query.format) ? req.query.format : 'txt';
const guest = await loadGuestOr404(eventId, guestId, res);
if (!guest) return;
const selections = await db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.guest_id', guestId)
.whereIn('photo_feedback.feedback_type', ['like', 'favorite'])
.select('photos.filename', 'photos.original_filename', 'photo_feedback.feedback_type');
const safeName = guest.name.replace(/[^a-zA-Z0-9_-]/g, '_') || `guest_${guest.id}`;
const filename = `${safeName}.${format}`;
if (format === 'json') {
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
return res.send(JSON.stringify({ guest: serializeGuest(guest), selections }, null, 2));
}
if (format === 'csv') {
res.setHeader('Content-Type', 'text/csv');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
const header = 'filename,original_filename,feedback_type';
const rows = selections.map(
(s) =>
`${escapeCsvCell(s.filename)},${escapeCsvCell(s.original_filename)},${escapeCsvCell(s.feedback_type)}`
);
return res.send([header, ...rows].join('\n'));
}
// txt — one filename per line
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
return res.send(selections.map((s) => s.original_filename || s.filename).join('\n'));
} catch (error) {
logger.error('Error exporting guest:', error);
res.status(500).json({ error: 'Failed to export guest' });
}
}
);
// ----------------------------------------------------------------------------
// DELETE /admin/events/:eventId/guests/:guestId — anonymize (soft delete)
// ----------------------------------------------------------------------------
router.delete(
'/events/:eventId/guests/:guestId',
adminAuth,
requirePermission('events.edit'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId, guestId } = req.params;
const guest = await loadGuestOr404(eventId, guestId, res);
if (!guest) return;
const result = await feedbackService.anonymizeGuestFeedback(guestId);
await db('gallery_guests').where({ id: guestId }).update({
is_deleted: true,
name: 'Removed',
email: null,
last_seen_at: db.fn.now(),
});
await logActivity(
'guest_deleted',
{ event_id: eventId, guest_id: guestId, anonymized: result.anonymized },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ success: true, ...result });
} catch (error) {
logger.error('Error deleting guest:', error);
res.status(500).json({ error: 'Failed to delete guest' });
}
}
);
// ----------------------------------------------------------------------------
// POST /admin/events/:eventId/guests/:keepId/merge — merge guests (Phase 3.4)
// Body: { mergeIds: number[] }
// ----------------------------------------------------------------------------
router.post(
'/events/:eventId/guests/:keepId/merge',
adminAuth,
requirePermission('events.edit'),
requireEventOwnership,
async (req, res) => {
try {
const { eventId, keepId } = req.params;
const mergeIds = Array.isArray(req.body?.mergeIds) ? req.body.mergeIds : [];
if (mergeIds.length === 0) {
return res.status(400).json({ error: 'mergeIds is required' });
}
if (mergeIds.includes(Number(keepId))) {
return res.status(400).json({ error: 'Cannot merge a guest into itself' });
}
// Sanity check: all guests belong to this event.
const all = await db('gallery_guests')
.whereIn('id', [Number(keepId), ...mergeIds.map(Number)])
.where({ event_id: eventId });
if (all.length !== mergeIds.length + 1) {
return res.status(400).json({ error: 'All guests must belong to the same event' });
}
const result = await feedbackService.mergeGuestFeedback(Number(keepId), mergeIds.map(Number));
// Soft-delete the merged (source) guests.
await db('gallery_guests')
.whereIn('id', mergeIds.map(Number))
.update({ is_deleted: true, last_seen_at: db.fn.now() });
await logActivity(
'guest_merged',
{ event_id: eventId, keep_id: keepId, merged_ids: mergeIds },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ success: true, ...result });
} catch (error) {
logger.error('Error merging guests:', error);
res.status(500).json({ error: 'Failed to merge guests' });
}
}
);
module.exports = router;
+16 -12
View File
@@ -3,11 +3,10 @@ const router = express.Router();
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const path = require('path');
const fs = require('fs').promises;
const logger = require('../utils/logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const { resolvePhotoFilePath } = require('../services/photoResolver');
// Module-level progress state
let repairProgress = {
@@ -23,13 +22,18 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
}
const photos = await db('photos')
.join('events', 'photos.event_id', 'events.id')
.where(function () {
this.whereNull('width').orWhereNull('height');
this.whereNull('photos.width').orWhereNull('photos.height');
})
.where(function () {
this.where('media_type', '!=', 'video').orWhereNull('media_type');
this.where('photos.media_type', '!=', 'video').orWhereNull('photos.media_type');
})
.select('id', 'path', 'filename');
.select(
'photos.id', 'photos.path', 'photos.filename',
'photos.source_origin', 'photos.external_relpath', 'photos.event_id',
'events.source_mode', 'events.external_path', 'events.slug'
);
if (photos.length === 0) {
return res.json({ message: 'No photos need dimension repair', count: 0 });
@@ -61,15 +65,16 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
for (const photo of photos) {
try {
if (!photo.path) {
logger.warn(`Photo ${photo.id} has no path, skipping dimension repair`);
const event = { source_mode: photo.source_mode, external_path: photo.external_path, slug: photo.slug };
let fullPath;
try {
fullPath = resolvePhotoFilePath(event, photo);
} catch (err) {
logger.warn(`Photo ${photo.id} has no resolvable path, skipping dimension repair: ${err.message}`);
errorCount++;
continue;
}
const storagePath = getStoragePath();
const fullPath = path.join(storagePath, 'events/active', photo.path);
try {
await fs.access(fullPath);
} catch (err) {
@@ -85,8 +90,7 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
.where({ id: photo.id })
.update({
width: metadata.width,
height: metadata.height,
updated_at: db.fn.now()
height: metadata.height
});
successCount++;
+99 -25
View File
@@ -14,6 +14,7 @@ const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploa
const { processUploadedPhotos } = require('../services/photoProcessor');
const chunkedUpload = require('../services/chunkedUploadService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const { requireEventOwnership } = require('../middleware/ownership');
const router = express.Router();
// Get storage path from environment or default
@@ -120,7 +121,7 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
// Upload photos for an event
// Max file count is configurable via general settings
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), uploadTimeout(600000), resolveAllowedTypes, async (req, res, next) => { // 10 minute timeout
router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), requireEventOwnership, uploadTimeout(600000), resolveAllowedTypes, async (req, res, next) => { // 10 minute timeout
let maxFilesPerUpload;
try {
maxFilesPerUpload = await getMaxFilesPerUpload();
@@ -170,7 +171,30 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
}
return res.status(404).json({ error: 'Event not found' });
}
// Enforce photo cap if set
if (event.photo_cap && event.photo_cap > 0) {
const existingPhotoCount = await db('photos')
.where({ event_id: eventId })
.count('id as count')
.first();
const currentCount = parseInt(existingPhotoCount.count) || 0;
const newFilesCount = (req.files && req.files.length) || 0;
if (currentCount + newFilesCount > event.photo_cap) {
// Clean up temp files
if (req.tempUploadPath) {
try {
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
} catch (e) {
console.error('Failed to clean up temp path:', e);
}
}
return res.status(400).json({
error: `Photo cap exceeded. This event allows a maximum of ${event.photo_cap} photos. Currently ${currentCount} photos exist, and you are trying to upload ${newFilesCount} more.`
});
}
}
if (!req.files || req.files.length === 0) {
console.error('No files in request. req.files:', req.files);
console.error('Request body keys:', Object.keys(req.body));
@@ -499,7 +523,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
});
// Delete a photo
router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.delete'), requireEventOwnership, async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -561,10 +585,10 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
});
// Update a photo (e.g., change category)
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), async (req, res) => {
router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.edit'), requireEventOwnership, async (req, res) => {
try {
const { eventId, photoId } = req.params;
const { category_id } = req.body;
const { category_id, visibility } = req.body;
// Verify photo belongs to event
const photo = await db('photos')
@@ -578,6 +602,13 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
// Prepare update data
const updateData = {};
// Handle visibility update (#172)
if (visibility !== undefined) {
if (['visible', 'hidden'].includes(visibility)) {
updateData.visibility = visibility;
}
}
// Handle type-based categories ('individual' or 'collage')
// These are string values that map to the photo.type field
if (category_id === 'individual' || category_id === 'collage') {
@@ -617,7 +648,7 @@ router.patch('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.e
});
// Bulk delete photos
router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos.delete'), async (req, res) => {
router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos.delete'), requireEventOwnership, async (req, res) => {
try {
const { eventId } = req.params;
const { photoIds } = req.body;
@@ -690,7 +721,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
});
// Bulk update photos
router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos.edit'), async (req, res) => {
router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos.edit'), requireEventOwnership, async (req, res) => {
try {
const { eventId } = req.params;
const { photoIds, updates } = req.body;
@@ -713,6 +744,13 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
// Prepare update data
const updateData = {};
// Handle visibility update (#172)
if (updates.visibility !== undefined) {
if (['visible', 'hidden'].includes(updates.visibility)) {
updateData.visibility = updates.visibility;
}
}
if (updates.category_id !== undefined) {
// Handle type-based categories ('individual' or 'collage')
// These are string values that map to the photo.type field
@@ -746,7 +784,7 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
});
// Download a photo
router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('photos.download'), async (req, res) => {
router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('photos.download'), requireEventOwnership, async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -778,17 +816,18 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, requirePermission('p
});
// Get all photos for an event
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), async (req, res) => {
router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
try {
const { eventId } = req.params;
const { category_id, type, search, sort = 'date' } = req.query;
const { category_id, type, search, sort = 'date', has_likes, has_favorites, has_comments, min_rating } = req.query;
const order = ['asc', 'desc'].includes(req.query.order) ? req.query.order : 'desc';
const logic = req.query.logic === 'OR' ? 'OR' : 'AND';
let query = db('photos')
.where({ 'photos.event_id': eventId })
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.select('photos.*', 'photo_categories.name as pc_name', 'photo_categories.slug as pc_slug');
// Filter by category_id
if (category_id !== undefined && category_id !== '' && category_id !== '0') {
if (category_id === 'individual' || category_id === 'collage') {
@@ -805,18 +844,53 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
}
}
}
// Keep type filter for backwards compatibility
if (type) {
query = query.where({ 'photos.type': type });
}
// Search by filename
if (search) {
const escapedSearch = escapeLikePattern(search);
query = query.where('photos.filename', 'like', `%${escapedSearch}%`);
}
// Feedback filters (has likes / favorites / comments / min rating) with AND/OR logic
const feedbackConditions = [];
if (has_likes === 'true' || has_likes === true) {
feedbackConditions.push(qb => qb.where('photos.like_count', '>', 0));
}
if (has_favorites === 'true' || has_favorites === true) {
feedbackConditions.push(qb => qb.where('photos.favorite_count', '>', 0));
}
if (has_comments === 'true' || has_comments === true) {
feedbackConditions.push(qb => qb.where('photos.comment_count', '>', 0));
}
if (min_rating !== undefined && min_rating !== null && min_rating !== '') {
const minRatingNum = parseFloat(min_rating);
if (!isNaN(minRatingNum)) {
feedbackConditions.push(qb => qb.where('photos.average_rating', '>=', minRatingNum));
}
}
if (feedbackConditions.length > 0) {
if (logic === 'OR') {
query = query.where(builder => {
feedbackConditions.forEach((cond, idx) => {
if (idx === 0) {
cond(builder);
} else {
builder.orWhere(sub => cond(sub));
}
});
});
} else {
feedbackConditions.forEach(cond => {
query = query.where(builder => cond(builder));
});
}
}
// Sorting
let orderByColumn = 'photos.uploaded_at';
if (sort === 'name') {
@@ -848,9 +922,9 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
filename: photo.filename,
original_filename: photo.original_filename || null,
// Use the correct admin photos router base for serving images
url: `/admin/photos/${eventId}/photo/${photo.id}`,
url: `/api/admin/photos/${eventId}/photo/${photo.id}`,
// Always expose a thumbnail URL; backend will generate on demand if missing
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
thumbnail_url: `/api/admin/photos/${eventId}/thumbnail/${photo.id}`,
type: photo.type,
category_id: photo.category_id || photo.type,
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
@@ -877,7 +951,7 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
});
// Serve photo with admin authentication
router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -914,7 +988,7 @@ router.get('/:eventId/photo/:photoId', adminAuth, requirePermission('photos.view
});
// Serve thumbnail with admin authentication
router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.view'), async (req, res) => {
router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
try {
const { eventId, photoId } = req.params;
@@ -954,7 +1028,7 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.
});
// Debug endpoint to check photo existence
router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), async (req, res) => {
router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
try {
const { eventId } = req.params;
@@ -980,7 +1054,7 @@ router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), async
// ============================================
// Initialize a chunked upload
router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), async (req, res) => {
router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
try {
const { eventId } = req.params;
const { filename, fileSize, mimeType, totalChunks } = req.body;
@@ -1018,7 +1092,7 @@ router.post('/:eventId/chunked-upload/init', adminAuth, requirePermission('photo
});
// Upload a chunk
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, requirePermission('photos.upload'), async (req, res) => {
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
try {
const { uploadId, chunkIndex } = req.params;
@@ -1039,7 +1113,7 @@ router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, r
});
// Complete chunked upload and process the file
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePermission('photos.upload'), async (req, res) => {
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePermission('photos.upload'), requireEventOwnership, async (req, res) => {
try {
const { eventId, uploadId } = req.params;
const { category_id } = req.body;
@@ -1081,7 +1155,7 @@ router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, requirePer
});
// Get upload status
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
try {
const { uploadId } = req.params;
@@ -1099,7 +1173,7 @@ router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, requirePermis
});
// Abort chunked upload
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission('photos.delete'), async (req, res) => {
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, requirePermission('photos.delete'), requireEventOwnership, async (req, res) => {
try {
const { uploadId } = req.params;
+10 -3
View File
@@ -114,7 +114,8 @@ router.post('/invite', [
const invitation = await userManagementService.createInvitation({
email: req.body.email,
roleId: req.body.role_id,
invitedById: req.admin.id
invitedById: req.admin.id,
inviterRoleName: req.admin.roleName
});
successResponse(res, { invitation }, 201);
@@ -146,7 +147,12 @@ router.get('/:id', [
param('id').isInt({ min: 1 }).withMessage('Valid user ID is required')
], handleAsync(async (req, res) => {
validateRequest(req);
const user = await userManagementService.getAdminUserById(parseInt(req.params.id));
const targetId = parseInt(req.params.id);
// Non-super_admin users can only view their own profile
if (req.admin.roleName !== 'super_admin' && targetId !== req.admin.id) {
return res.status(403).json({ error: 'Access denied' });
}
const user = await userManagementService.getAdminUserById(targetId);
res.json({ user: transformUser(user) });
}));
@@ -169,7 +175,8 @@ router.put('/:id', [
const user = await userManagementService.updateAdminUser(
parseInt(req.params.id),
req.body,
req.admin.id
req.admin.id,
{ roleName: req.admin.roleName }
);
successResponse(res, { user: transformUser(user), message: 'User updated successfully' });
+112 -8
View File
@@ -13,6 +13,7 @@ const {
getGenericAuthError
} = require('../utils/authSecurity');
const { endSession } = require('../middleware/sessionTimeout');
const { revokeToken } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
const {
setAdminAuthCookie,
@@ -117,9 +118,8 @@ router.post('/admin/login', [
setAdminAuthCookie(res, token);
// Include role in response
// Token is delivered via HttpOnly cookie only (not in response body)
res.json({
token,
user: {
id: admin.id,
username: admin.username,
@@ -145,7 +145,8 @@ router.post('/logout', async (req, res) => {
const token = adminToken || galleryToken;
if (token) {
// End the session
// Revoke the token so it can't be reused, then end the session
await revokeToken(token, 'user_logout');
endSession(token);
try {
@@ -198,6 +199,8 @@ router.post('/gallery/verify', [
.first();
if (!event) {
// Perform a dummy bcrypt compare to prevent timing-based slug enumeration
await bcrypt.compare(password || '', '$2b$10$abcdefghijklmnopqrstuuABCDEFGHIJKLMNOPQRSTUVWXYZ01234');
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
@@ -280,7 +283,8 @@ router.post('/gallery/verify', [
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: requiresPassword
require_password: requiresPassword,
photo_cap: event.photo_cap
}
});
} catch (error) {
@@ -289,6 +293,82 @@ router.post('/gallery/verify', [
}
});
// Client access login (PIN-based)
router.post('/gallery/:slug/client-login', [
body('password').notEmpty().isString()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slug } = req.params;
const { password } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event || !event.client_access_enabled || !event.client_password_hash) {
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid credentials' });
}
const lockoutStatus = await checkAccountLockout(`client:${slug}`, ipAddress);
if (lockoutStatus.isLocked) {
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
const validPassword = await bcrypt.compare(password, event.client_password_hash);
if (!validPassword) {
await trackFailedAttempt(`client:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid credentials' });
}
await trackSuccessfulLogin(`client:${slug}`, ipAddress, userAgent);
const token = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
accessLevel: 'client',
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setGalleryAuthCookies(res, token, event.slug);
res.json({
token,
event: {
id: event.id,
event_name: event.event_name,
event_type: event.event_type,
event_date: event.event_date,
welcome_message: event.welcome_message,
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: true
},
accessLevel: 'client'
});
} catch (error) {
logger.error('Client login error:', error);
res.status(500).json({ error: 'Authentication failed' });
}
});
// Share link authentication (token-based)
router.post('/gallery/share-login', [
body('slug').notEmpty().trim(),
@@ -304,6 +384,17 @@ router.post('/gallery/share-login', [
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Rate limit share-link login attempts
const shareIdentifier = `gallery:${slug}:share`;
const lockoutStatus = await checkAccountLockout(shareIdentifier, ipAddress);
if (lockoutStatus.isLocked) {
logger.warn('Share link login attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
let event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
@@ -316,12 +407,14 @@ router.post('/gallery/share-login', [
}
if (!event) {
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
return res.status(404).json({ error: 'Gallery not found' });
}
const expectedToken = getEventShareToken(event);
if (!expectedToken || token !== expectedToken) {
await trackFailedAttempt(shareIdentifier, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid or expired share link' });
}
@@ -353,7 +446,8 @@ router.post('/gallery/share-login', [
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id,
require_password: requiresPassword
require_password: requiresPassword,
photo_cap: event.photo_cap
}
});
} catch (error) {
@@ -362,10 +456,14 @@ router.post('/gallery/share-login', [
}
});
// Gallery logout to clear cookies
// Gallery logout to clear cookies and revoke token
router.post('/gallery/logout', async (req, res) => {
try {
const { slug } = req.body || {};
const token = getGalleryTokenFromRequest(req, slug);
if (token) {
await revokeToken(token, 'gallery_logout');
}
clearGalleryAuthCookies(res, slug);
res.json({ message: 'Logged out successfully' });
} catch (error) {
@@ -386,11 +484,17 @@ router.get('/session', async (req, res) => {
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Check if token has been revoked (e.g. after logout)
const { isTokenRevoked } = require('../utils/tokenRevocation');
if (await isTokenRevoked(decoded)) {
return res.status(401).json({ valid: false, error: 'Session has been invalidated' });
}
// Calculate remaining time
const now = Date.now() / 1000;
const remainingTime = Math.max(0, decoded.exp - now);
res.json({
valid: true,
type: decoded.type,
+131 -18
View File
@@ -6,7 +6,7 @@ const path = require('path');
const router = express.Router();
const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const { verifyGalleryAccess } = require('../middleware/gallery');
const { verifyGalleryAccess, isAdminPreview } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver');
@@ -74,7 +74,7 @@ router.get('/:slug/verify-token/:token', handleAsync(async (req, res) => {
const { slug, token } = req.params;
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false), is_draft: formatBoolean(false) })
.select('id', 'share_link', 'share_token')
.first();
@@ -122,7 +122,9 @@ router.get('/:slug/info', async (req, res) => {
'hero_logo_url',
'header_style',
'hero_divider_style',
'hero_image_anchor'
'hero_image_anchor',
'is_draft',
'default_photo_sort'
)
.first();
@@ -138,11 +140,16 @@ router.get('/:slug/info', async (req, res) => {
}
return res.status(404).json({ error: 'Gallery not found' });
}
// Check if event is archived
if (event.is_archived) {
return res.status(404).json({ error: 'Gallery has been archived and is no longer available' });
}
// Check if event is a draft (allow admin preview)
if (event.is_draft && !isAdminPreview(req)) {
return res.status(404).json({ error: 'Gallery is not yet published' });
}
// If token provided, verify it matches the share link
if (token) {
@@ -176,7 +183,8 @@ router.get('/:slug/info', async (req, res) => {
hero_logo_url: event.hero_logo_url || null,
header_style: event.header_style || 'standard',
hero_divider_style: event.hero_divider_style || 'wave',
hero_image_anchor: event.hero_image_anchor || 'center'
hero_image_anchor: event.hero_image_anchor || 'center',
default_photo_sort: event.default_photo_sort || 'upload_date_desc'
});
} catch (error) {
console.error('Error fetching gallery info:', error);
@@ -198,10 +206,18 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
// Build the query with sorting
const sortOrder = order === 'asc' ? 'asc' : 'desc';
const isClient = req.accessLevel === 'client';
let photosQuery = db('photos')
.where('photos.event_id', req.event.id)
.select('photos.*');
// Guests only see visible photos; clients see all
if (!isClient) {
photosQuery = photosQuery.where(function() {
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
});
}
// Apply sort option
if (sort === 'capture_date') {
// Sort by capture date, falling back to uploaded_at if capture date is null
@@ -295,6 +311,11 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
}
}
// Check if feedback should be visible to guests
const feedbackService = require('../services/feedbackService');
const feedbackSettings = await feedbackService.getEventFeedbackSettings(req.event.id);
const showFeedbackToGuests = isClient || feedbackSettings.show_feedback_to_guests !== false;
// Then get comment counts separately
const commentCounts = await db('photo_feedback')
.whereIn('photo_id', photos.map(p => p.id))
@@ -383,6 +404,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
header_style: req.event.header_style || 'standard',
hero_divider_style: req.event.hero_divider_style || 'wave',
hero_image_anchor: req.event.hero_image_anchor || 'center',
default_photo_sort: req.event.default_photo_sort || 'upload_date_desc',
...protectionSettings
},
categories: categories,
@@ -414,12 +436,20 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
height: photo.height || null,
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
requires_token: !useJwtUrl,
// Feedback data
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
average_rating: photo.average_rating || 0,
comment_count: commentMap[photo.id] || 0,
like_count: photo.like_count || 0,
favorite_count: photo.favorite_count || 0
// EXIF capture date
captured_at: photo.captured_at || null,
// Media type
media_type: photo.media_type || null,
mime_type: photo.mime_type || null,
duration: photo.duration || null,
// Feedback data (hidden when show_feedback_to_guests is disabled)
has_feedback: showFeedbackToGuests ? (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0) : false,
average_rating: showFeedbackToGuests ? (photo.average_rating || 0) : 0,
comment_count: showFeedbackToGuests ? (commentMap[photo.id] || 0) : 0,
like_count: showFeedbackToGuests ? (photo.like_count || 0) : 0,
favorite_count: showFeedbackToGuests ? (photo.favorite_count || 0) : 0,
// Visibility (only included for clients)
...(isClient ? { visibility: photo.visibility || 'visible' } : {})
};
})
});
@@ -429,24 +459,91 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
}
});
// Toggle photo visibility (client-only)
router.patch('/:slug/photos/:photoId/visibility', verifyGalleryAccess, async (req, res) => {
try {
if (req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Client access required' });
}
const { photoId } = req.params;
const { visibility } = req.body;
if (!['visible', 'hidden'].includes(visibility)) {
return res.status(400).json({ error: 'Invalid visibility value' });
}
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' });
}
await db('photos')
.where({ id: photoId, event_id: req.event.id })
.update({ visibility });
res.json({ message: 'Photo visibility updated', visibility });
} catch (error) {
logger.error('Error updating photo visibility:', error);
res.status(500).json({ error: 'Failed to update photo visibility' });
}
});
// Bulk toggle photo visibility (client-only)
router.patch('/:slug/photos/visibility/bulk', verifyGalleryAccess, async (req, res) => {
try {
if (req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Client access required' });
}
const { photoIds, visibility } = req.body;
if (!Array.isArray(photoIds) || photoIds.length === 0) {
return res.status(400).json({ error: 'Invalid photo IDs' });
}
if (!['visible', 'hidden'].includes(visibility)) {
return res.status(400).json({ error: 'Invalid visibility value' });
}
const count = await db('photos')
.whereIn('id', photoIds)
.where('event_id', req.event.id)
.update({ visibility });
res.json({ message: `${count} photos updated`, visibility });
} catch (error) {
logger.error('Error bulk updating photo visibility:', error);
res.status(500).json({ error: 'Failed to update photo visibility' });
}
});
// Download single photo
router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) => {
try {
const { photoId } = req.params;
// Check if downloads are allowed for this event
if (req.event.allow_downloads === false) {
return res.status(403).json({ error: 'Downloads are disabled for this gallery' });
}
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' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Update download count
await db('photos').where('id', photoId).increment('download_count', 1);
@@ -745,11 +842,15 @@ router.get('/:slug/photo/:photoId',
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Check if this is a video
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
@@ -935,6 +1036,11 @@ router.get('/:slug/thumbnail/:photoId',
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Ensure thumbnail exists and is valid, regenerate if needed
const thumbnailPath = await ensureThumbnail(photo);
@@ -1013,6 +1119,11 @@ router.get('/:slug/hero/:photoId',
return res.status(404).json({ error: 'Photo not found' });
}
// Block guest access to hidden photos
if (photo.visibility === 'hidden' && req.accessLevel !== 'client') {
return res.status(403).json({ error: 'Photo not available' });
}
// Check if this is a video - videos don't get hero images
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
if (isVideo) {
@@ -1093,10 +1204,12 @@ router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) =>
res.json({
feedback_enabled: settings.feedback_enabled || false,
allow_ratings: settings.allow_ratings,
allow_likes: settings.allow_likes,
allow_likes: settings.allow_likes,
allow_comments: settings.allow_comments,
allow_favorites: settings.allow_favorites,
show_feedback_to_guests: settings.show_feedback_to_guests
show_feedback_to_guests: settings.show_feedback_to_guests,
require_name_email: settings.require_name_email || false,
identity_mode: settings.identity_mode || 'simple'
});
} catch (error) {
console.error('Error fetching feedback settings:', error);
+64 -32
View File
@@ -3,6 +3,7 @@ const router = express.Router();
const { photoAuth } = require('../middleware/photoAuth');
const { verifyGalleryAccess } = require('../middleware/gallery');
const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
const { resolveGuest } = require('../middleware/guestAuth');
const feedbackService = require('../services/feedbackService');
const feedbackModeration = require('../services/feedbackModeration');
const { db, logActivity } = require('../database/db');
@@ -22,7 +23,7 @@ router.get('/:slug/feedback-settings',
try {
const event = req.event;
const settings = await feedbackService.getEventFeedbackSettings(event.id);
// Only send relevant settings to guests
// Convert SQLite boolean values (0/1) to proper booleans
const guestSettings = {
@@ -32,9 +33,10 @@ router.get('/:slug/feedback-settings',
allow_comments: Boolean(settings.allow_comments),
allow_favorites: Boolean(settings.allow_favorites),
require_name_email: Boolean(settings.require_name_email),
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests)
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests),
identity_mode: settings.identity_mode || 'simple'
};
res.json(guestSettings);
} catch (error) {
logger.error('Error getting feedback settings:', error);
@@ -46,6 +48,7 @@ router.get('/:slug/feedback-settings',
// Get feedback for a specific photo
router.get('/:slug/photos/:photoId/feedback',
verifyGalleryAccess,
resolveGuest,
validatePhotoId,
checkValidation,
async (req, res) => {
@@ -137,6 +140,7 @@ router.get('/:slug/photos/:photoId/feedback',
// Submit feedback for a photo
router.post('/:slug/photos/:photoId/feedback',
verifyGalleryAccess,
resolveGuest,
validatePhotoId,
validateFeedbackSubmission,
checkValidation,
@@ -144,15 +148,28 @@ router.post('/:slug/photos/:photoId/feedback',
try {
const { photoId } = req.params;
const event = req.event;
const guestIdentifier = generateGuestIdentifier(req);
// Get feedback settings
// Get feedback settings first so we can enforce identity_mode.
const settings = await feedbackService.getEventFeedbackSettings(event.id);
if (!settings.feedback_enabled) {
return res.status(403).json({ error: 'Feedback is not enabled for this event' });
}
// In guest identity mode, a valid guest token is required. The server
// never trusts guest_name/guest_email from the body in this mode — it
// reads them from the verified token via req.guest.
if (settings.identity_mode === 'guest') {
if (!req.guest || req.guest.eventId !== event.id) {
return res.status(401).json({
error: 'Guest identity required',
code: 'GUEST_IDENTITY_REQUIRED'
});
}
}
const guestIdentifier = generateGuestIdentifier(req);
// Check if specific feedback type is allowed
const feedbackType = req.body.feedback_type;
const typeAllowed = {
@@ -161,29 +178,32 @@ router.post('/:slug/photos/:photoId/feedback',
comment: settings.allow_comments,
favorite: settings.allow_favorites
};
if (!typeAllowed[feedbackType]) {
return res.status(403).json({ error: `${feedbackType} feedback is not enabled` });
}
// Verify photo belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Validate guest requirements
const guestValidation = await validateGuestRequirements(settings, req.body);
if (!guestValidation.valid) {
return res.status(400).json({
error: 'Guest information required',
errors: guestValidation.errors
});
// Validate guest requirements only in simple mode. In guest mode, the
// identity is already provided via the token and verified above.
if (settings.identity_mode !== 'guest') {
const guestValidation = await validateGuestRequirements(settings, req.body);
if (!guestValidation.valid) {
return res.status(400).json({
error: 'Guest information required',
errors: guestValidation.errors
});
}
}
// Apply rate limiting based on feedback type
const rateLimitMiddleware = feedbackRateLimit(feedbackType);
await new Promise((resolve, reject) => {
@@ -192,19 +212,21 @@ router.post('/:slug/photos/:photoId/feedback',
else resolve();
});
});
// If we got here and response was sent (rate limited), return
if (res.headersSent) return;
// Prepare feedback data
// Prepare feedback data. In guest mode, use the verified token as the
// source of truth for name/email — never the body.
const feedbackData = {
feedback_type: feedbackType,
rating: req.body.rating,
comment_text: req.body.comment_text,
guest_name: req.body.guest_name,
guest_email: req.body.guest_email,
guest_name: req.guest?.name ?? req.body.guest_name,
guest_email: req.guest?.email ?? req.body.guest_email,
guest_id: req.guest?.id ?? null,
ip_address: req.ip || req.connection.remoteAddress,
user_agent: req.headers['user-agent'],
user_agent: (req.headers['user-agent'] || '').replace(/[<>&"']/g, '').substring(0, 255),
moderate_comments: settings.moderate_comments
};
@@ -316,22 +338,32 @@ router.get('/:slug/feedback-summary',
// Get user's own feedback for all photos
router.get('/:slug/my-feedback',
verifyGalleryAccess,
resolveGuest,
async (req, res) => {
try {
const event = req.event;
const guestIdentifier = generateGuestIdentifier(req);
const myFeedback = await db('photo_feedback')
const query = db('photo_feedback')
.join('photos', 'photo_feedback.photo_id', 'photos.id')
.where('photo_feedback.event_id', event.id)
.where('photo_feedback.guest_identifier', guestIdentifier)
.where('photo_feedback.event_id', event.id);
// Prefer guest_id lookup when a verified guest token is present
// (per-person identity). Fall back to the device hash otherwise.
if (req.guest?.id) {
query.where('photo_feedback.guest_id', req.guest.id);
} else {
const guestIdentifier = generateGuestIdentifier(req);
query.where('photo_feedback.guest_identifier', guestIdentifier);
}
const myFeedback = await query
.select(
'photo_feedback.*',
'photos.filename',
'photos.path'
)
.orderBy('photo_feedback.created_at', 'desc');
res.json(myFeedback);
} catch (error) {
logger.error('Error getting user feedback:', error);
+409
View File
@@ -0,0 +1,409 @@
const express = require('express');
const crypto = require('crypto');
const router = express.Router();
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { verifyGalleryAccess } = require('../middleware/gallery');
const { resolveGuest, requireGuest, signGuestToken } = require('../middleware/guestAuth');
const feedbackService = require('../services/feedbackService');
const guestRecovery = require('../services/guestRecoveryService');
const MAX_NAME_LEN = 100;
const MAX_EMAIL_LEN = 255;
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// In-memory rate limit for guest registration (20 per hour per IP). Simple
// sliding window; on process restart the counters reset which is acceptable.
const registrationAttempts = new Map();
const REGISTRATION_WINDOW_MS = 60 * 60 * 1000;
const REGISTRATION_MAX = 20;
function checkRegistrationRate(ip) {
const now = Date.now();
const entry = registrationAttempts.get(ip) || { count: 0, windowStart: now };
if (now - entry.windowStart > REGISTRATION_WINDOW_MS) {
entry.count = 0;
entry.windowStart = now;
}
entry.count += 1;
registrationAttempts.set(ip, entry);
return entry.count <= REGISTRATION_MAX;
}
function sanitizeName(value) {
if (typeof value !== 'string') return '';
// Strip HTML/control chars, collapse whitespace.
const cleaned = value
.replace(/[<>&"']/g, '')
.replace(/[\u0000-\u001F\u007F]/g, '')
.replace(/\s+/g, ' ')
.trim();
return cleaned.slice(0, MAX_NAME_LEN);
}
function sanitizeEmail(value) {
if (typeof value !== 'string') return '';
return value.trim().slice(0, MAX_EMAIL_LEN).toLowerCase();
}
/**
* POST /gallery/:slug/guest
* Body: { name, email? }
*
* Registers a new per-person guest identity for this gallery. Returns a JWT
* that the frontend must send as the x-guest-token header on subsequent
* feedback requests.
*/
router.post('/:slug/guest', verifyGalleryAccess, async (req, res) => {
try {
const ip = req.ip || req.connection.remoteAddress || 'unknown';
if (!checkRegistrationRate(ip)) {
return res.status(429).json({ error: 'Too many registration attempts' });
}
const event = req.event;
const settings = await feedbackService.getEventFeedbackSettings(event.id);
// Guest registration is only meaningful when feedback is enabled.
if (!settings.feedback_enabled) {
return res.status(403).json({ error: 'Feedback is not enabled for this gallery' });
}
const name = sanitizeName(req.body?.name);
if (!name || name.length < 1) {
return res.status(400).json({ error: 'Name is required', field: 'name' });
}
let email = sanitizeEmail(req.body?.email);
if (email && !EMAIL_REGEX.test(email)) {
return res.status(400).json({ error: 'Invalid email format', field: 'email' });
}
if (settings.require_name_email && !email) {
return res.status(400).json({ error: 'Email is required', field: 'email' });
}
const identifier = crypto.randomUUID();
const userAgent = (req.headers['user-agent'] || '').substring(0, 500);
const [row] = await db('gallery_guests')
.insert({
event_id: event.id,
name,
email: email || null,
identifier,
ip_address_last: ip.substring(0, 45),
user_agent_last: userAgent,
})
.returning(['id', 'name', 'email', 'identifier', 'created_at']);
const token = signGuestToken({
guestId: row.id,
eventId: event.id,
identifier: row.identifier,
name: row.name,
});
logger.info('Guest registered', {
eventId: event.id,
guestId: row.id,
name: row.name,
});
return res.json({
guest: {
id: row.id,
name: row.name,
email: row.email,
identifier: row.identifier,
},
token,
});
} catch (error) {
logger.error('Guest registration failed', { error: error.message });
return res.status(500).json({ error: 'Failed to register guest' });
}
});
/**
* GET /gallery/:slug/guest/me
* Returns the current guest profile from a valid guest token. 401 otherwise.
*/
router.get('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => {
try {
if (req.guest.eventId !== req.event.id) {
return res.status(403).json({ error: 'Guest token does not match gallery' });
}
// Update last_seen_at on each profile fetch (cheap and useful for admin).
await db('gallery_guests')
.where({ id: req.guest.id })
.update({
last_seen_at: db.fn.now(),
ip_address_last: (req.ip || '').substring(0, 45),
user_agent_last: (req.headers['user-agent'] || '').substring(0, 500),
});
return res.json({
guest: {
id: req.guest.id,
name: req.guest.name,
email: req.guest.email,
identifier: req.guest.identifier,
},
});
} catch (error) {
logger.error('Guest profile fetch failed', { error: error.message });
return res.status(500).json({ error: 'Failed to fetch guest profile' });
}
});
/**
* DELETE /gallery/:slug/guest/me
*
* "Forget me" — soft-deletes the guest row and anonymizes their feedback so
* aggregate counts remain stable but personal data is removed.
*/
router.delete('/:slug/guest/me', verifyGalleryAccess, resolveGuest, requireGuest, async (req, res) => {
try {
if (req.guest.eventId !== req.event.id) {
return res.status(403).json({ error: 'Guest token does not match gallery' });
}
await feedbackService.anonymizeGuestFeedback(req.guest.id);
await db('gallery_guests')
.where({ id: req.guest.id })
.update({
is_deleted: true,
name: 'Removed',
email: null,
last_seen_at: db.fn.now(),
});
logger.info('Guest self-forgot', {
eventId: req.event.id,
guestId: req.guest.id,
});
return res.json({ success: true });
} catch (error) {
logger.error('Guest forget-me failed', { error: error.message });
return res.status(500).json({ error: 'Failed to forget guest' });
}
});
// ---------------------------------------------------------------------------
// Phase 3.2 — Email-based identity recovery
// ---------------------------------------------------------------------------
// Simple in-memory rate limit for recover/verify (5 per hour per IP).
const recoveryAttempts = new Map();
const VERIFY_WINDOW_MS = 60 * 60 * 1000;
const VERIFY_MAX = 20;
function checkRecoveryRate(ip) {
const now = Date.now();
const entry = recoveryAttempts.get(ip) || { count: 0, windowStart: now };
if (now - entry.windowStart > VERIFY_WINDOW_MS) {
entry.count = 0;
entry.windowStart = now;
}
entry.count += 1;
recoveryAttempts.set(ip, entry);
return entry.count <= VERIFY_MAX;
}
/**
* POST /gallery/:slug/guest/recover
* Body: { email }
*
* Sends a 6-digit code to the email if it matches an existing guest. Returns
* 200 regardless of whether a matching guest exists (prevents enumeration).
*/
router.post('/:slug/guest/recover', verifyGalleryAccess, async (req, res) => {
try {
const ip = req.ip || 'unknown';
if (!checkRecoveryRate(ip)) {
return res.status(429).json({ error: 'Too many recovery attempts' });
}
const email = sanitizeEmail(req.body?.email);
if (!email || !EMAIL_REGEX.test(email)) {
// Still return 200 to avoid leaking validity of the email field.
return res.json({ success: true });
}
const event = req.event;
const settings = await feedbackService.getEventFeedbackSettings(event.id);
if (!settings.feedback_enabled || settings.identity_mode !== 'guest') {
return res.json({ success: true });
}
const guest = await db('gallery_guests')
.where({ event_id: event.id, email, is_deleted: false })
.first();
if (guest) {
try {
const code = await guestRecovery.createCode(event.id, email);
await guestRecovery.sendRecoveryEmail(email, code, event.event_name || 'your gallery');
} catch (sendError) {
logger.error('Failed to send recovery email', { error: sendError.message });
// Still return 200 so clients can't distinguish failures.
}
}
return res.json({ success: true });
} catch (error) {
logger.error('Guest recovery request failed', { error: error.message });
return res.json({ success: true });
}
});
/**
* POST /gallery/:slug/guest/verify
* Body: { email, code }
*
* Exchanges a valid verification code for a guest token. Reuses the existing
* guest row associated with the email (the guest continues where they left
* off, cross-device).
*/
router.post('/:slug/guest/verify', verifyGalleryAccess, async (req, res) => {
try {
const ip = req.ip || 'unknown';
if (!checkRecoveryRate(ip)) {
return res.status(429).json({ error: 'Too many verification attempts' });
}
const email = sanitizeEmail(req.body?.email);
const code = String(req.body?.code || '').trim();
if (!email || !code) {
return res.status(400).json({ error: 'Email and code are required' });
}
const event = req.event;
const verifyResult = await guestRecovery.verifyCode(event.id, email, code);
if (!verifyResult.ok) {
return res.status(401).json({ error: 'Invalid or expired code', reason: verifyResult.reason });
}
const guest = await db('gallery_guests')
.where({ event_id: event.id, email, is_deleted: false })
.first();
if (!guest) {
return res.status(404).json({ error: 'Guest not found' });
}
await db('gallery_guests')
.where({ id: guest.id })
.update({
email_verified_at: guest.email_verified_at || db.fn.now(),
last_seen_at: db.fn.now(),
ip_address_last: (req.ip || '').substring(0, 45),
});
const token = signGuestToken({
guestId: guest.id,
eventId: event.id,
identifier: guest.identifier,
name: guest.name,
});
logger.info('Guest recovered via email', { eventId: event.id, guestId: guest.id });
return res.json({
guest: {
id: guest.id,
name: guest.name,
email: guest.email,
identifier: guest.identifier,
},
token,
});
} catch (error) {
logger.error('Guest verify failed', { error: error.message });
return res.status(500).json({ error: 'Failed to verify code' });
}
});
// ---------------------------------------------------------------------------
// Phase 3.3 — Invite token redemption
// ---------------------------------------------------------------------------
/**
* POST /gallery/:slug/guest/redeem
* Body: { inviteToken }
*
* Redeems a pre-minted invite token (created by admin). Single use.
*/
router.post('/:slug/guest/redeem', verifyGalleryAccess, async (req, res) => {
try {
const inviteToken = String(req.body?.inviteToken || '').trim();
if (!inviteToken) {
return res.status(400).json({ error: 'Invite token required' });
}
const event = req.event;
const result = await db.transaction(async (trx) => {
const invite = await trx('guest_invites')
.where({ token: inviteToken, event_id: event.id })
.first();
if (!invite) return { error: 'not_found' };
if (invite.revoked_at) return { error: 'revoked' };
if (invite.redeemed_at) return { error: 'already_redeemed' };
const guest = await trx('gallery_guests')
.where({ id: invite.guest_id, is_deleted: false })
.first();
if (!guest) return { error: 'guest_missing' };
await trx('guest_invites')
.where({ id: invite.id })
.update({ redeemed_at: trx.fn.now() });
await trx('gallery_guests')
.where({ id: guest.id })
.update({
last_seen_at: trx.fn.now(),
ip_address_last: (req.ip || '').substring(0, 45),
user_agent_last: (req.headers['user-agent'] || '').substring(0, 500),
});
return { guest };
});
if (result.error) {
const statusMap = {
not_found: 404,
revoked: 410,
already_redeemed: 409,
guest_missing: 404,
};
return res.status(statusMap[result.error] || 400).json({ error: result.error });
}
const token = signGuestToken({
guestId: result.guest.id,
eventId: event.id,
identifier: result.guest.identifier,
name: result.guest.name,
});
logger.info('Invite redeemed', { eventId: event.id, guestId: result.guest.id });
return res.json({
guest: {
id: result.guest.id,
name: result.guest.name,
email: result.guest.email,
identifier: result.guest.identifier,
},
token,
});
} catch (error) {
logger.error('Invite redemption failed', { error: error.message });
return res.status(500).json({ error: 'Failed to redeem invite' });
}
});
module.exports = router;
+4 -27
View File
@@ -350,34 +350,11 @@ router.get('/:slug/secure-download/:photoId/:token',
/**
* Get security statistics for monitoring
*/
router.get('/security/stats', async (req, res) => {
try {
// Only allow admin access
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const jwt = require('jsonwebtoken');
// Try to verify with issuer first, fallback to no issuer for backward compatibility
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET, {
issuer: 'picpeak-auth'
});
} catch (issuerError) {
// If verification fails with issuer, try without issuer (backward compatibility)
if (issuerError.name === 'JsonWebTokenError' && issuerError.message.includes('jwt issuer invalid')) {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} else {
throw issuerError;
}
}
const admin = await db('admin_users').where({ id: decoded.id }).first();
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
}
router.get('/security/stats', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Get security statistics
const stats = {
+38 -5
View File
@@ -437,26 +437,59 @@ async function performLocalBackup(config, files) {
};
}
function validateRsyncParam(value, label) {
if (!value || typeof value !== 'string') return null;
if (!/^[a-zA-Z0-9._\/@:-]+$/.test(value)) {
throw new Error(`Invalid ${label}: contains disallowed characters`);
}
if (value.length > 1024) {
throw new Error(`Invalid ${label}: too long`);
}
return value;
}
function buildRsyncArgs(config) {
const storagePath = getStoragePath();
const host = config.backup_rsync_host;
const remotePath = config.backup_rsync_path;
const host = validateRsyncParam(config.backup_rsync_host, 'host');
const remotePath = validateRsyncParam(config.backup_rsync_path, 'remote path');
if (!host || !remotePath) {
throw new Error('Rsync configuration incomplete');
}
// Validate host format (hostname or IP only)
const hostRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
const ipRegex = /^(\d{1,3}\.){3}\d{1,3}$/;
if (!hostRegex.test(host) && !ipRegex.test(host)) {
throw new Error('Invalid rsync host format');
}
const args = ['-avz', '--delete', '--stats'];
if (config.backup_rsync_ssh_key) {
args.push('-e', `ssh -i ${config.backup_rsync_ssh_key} -o StrictHostKeyChecking=no`);
const sshKey = validateRsyncParam(config.backup_rsync_ssh_key, 'SSH key path');
const fs = require('fs');
if (!fs.existsSync(sshKey) || !fs.statSync(sshKey).isFile()) {
throw new Error('SSH key file not found or is not a file');
}
// Pass SSH options as separate array elements to avoid shell interpretation
args.push('-e', `ssh -i ${sshKey} -o StrictHostKeyChecking=no`);
}
const excludePatterns = config.backup_exclude_patterns || [];
excludePatterns.forEach(pattern => args.push('--exclude', pattern));
const source = `${storagePath}/`;
const destination = config.backup_rsync_user
? `${config.backup_rsync_user}@${host}:${remotePath}`
const user = config.backup_rsync_user;
if (user) {
validateRsyncParam(user, 'user');
if (!/^[a-zA-Z_][a-zA-Z0-9_-]*$/.test(user)) {
throw new Error('Invalid rsync username format');
}
}
const destination = user
? `${user}@${host}:${remotePath}`
: `${host}:${remotePath}`;
args.push(source, destination);
+218 -93
View File
@@ -2,6 +2,7 @@ const nodemailer = require('nodemailer');
const Handlebars = require('handlebars');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
let transporter = null;
let lastConfigHash = null;
@@ -99,113 +100,74 @@ async function getRecipientLanguage(email, eventId = null) {
logger.error('Error fetching email config language:', error);
}
// Fourth priority: Check if the email domain suggests German
// Fourth priority: Check if the email domain suggests a language
if (email) {
const germanDomains = ['.de', '.at', '.ch', '.li'];
const domain = email.toLowerCase();
if (germanDomains.some(d => domain.endsWith(d))) {
return 'de';
const domainLanguageMap = [
{ domains: ['.de', '.at', '.ch', '.li'], language: 'de' },
{ domains: ['.nl', '.be'], language: 'nl' },
{ domains: ['.br', '.pt'], language: 'pt' },
{ domains: ['.ru', '.su'], language: 'ru' },
];
for (const { domains, language: lang } of domainLanguageMap) {
if (domains.some(d => domain.endsWith(d))) {
return lang;
}
}
}
return 'en'; // Default to English
}
// Process email template with variables
async function processTemplate(template, variables, language = 'en') {
// Import date formatter and text formatters
const { formatDate } = require('../utils/dateFormatter');
const { formatWelcomeMessage } = require('../utils/formatters');
// Get the appropriate language fields
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
const textField = language === 'de' ? 'body_text_de' : 'body_text_en';
// Fall back to non-language-specific fields for backward compatibility
let subject = template[subjectField] || template.subject || '';
let htmlBody = template[htmlField] || template.body_html || '';
let textBody = template[textField] || template.body_text || '';
// Process variables before template compilation
const processedVariables = { ...variables };
// Handle password security message
if (processedVariables.gallery_password === '{{password_security_message}}') {
processedVariables.gallery_password = language === 'de'
? '(Aus Sicherheitsgründen nicht angezeigt)'
: '(Not shown for security reasons)';
}
// Darken a hex color by a percentage (0-1)
function darkenColor(hex, amount = 0.15) {
const num = parseInt(hex.replace('#', ''), 16);
const r = Math.max(0, Math.min(255, ((num >> 16) & 0xFF) * (1 - amount)));
const g = Math.max(0, Math.min(255, ((num >> 8) & 0xFF) * (1 - amount)));
const b = Math.max(0, Math.min(255, (num & 0xFF) * (1 - amount)));
return `#${(1 << 24 | Math.round(r) << 16 | Math.round(g) << 8 | Math.round(b)).toString(16).slice(1)}`;
}
if (processedVariables.gallery_password === 'No password required') {
processedVariables.gallery_password = language === 'de'
? 'Kein Passwort erforderlich'
: 'No password required';
}
// Format dates if they exist
if (processedVariables.event_date) {
processedVariables.event_date = await formatDate(processedVariables.event_date, language);
}
if (processedVariables.expiry_date) {
processedVariables.expiry_date = await formatDate(processedVariables.expiry_date, language);
}
if (processedVariables.archive_date) {
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
}
if (processedVariables.expires_at) {
processedVariables.expires_at = await formatDate(processedVariables.expires_at, language);
}
// Format welcome message for HTML display (preserve line breaks)
if (processedVariables.welcome_message) {
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
}
// Get branding settings for logo
// Wrap HTML body in the styled email template with header, footer, and logo
async function wrapEmailHtml(htmlBody, subject, language = 'en') {
// Get branding settings for logo and email colors
let logoUrl = '';
let companyName = 'PicPeak';
let primaryColor = '#5C8762';
let secondaryColor = '#f9f9f9';
try {
const brandingSettings = await db('app_settings')
.whereIn('setting_key', ['branding_logo_url', 'branding_company_name'])
.whereIn('setting_key', [
'branding_logo_url', 'branding_company_name',
'email_primary_color', 'email_secondary_color'
])
.select('setting_key', 'setting_value');
brandingSettings.forEach(setting => {
if (setting.setting_key === 'branding_logo_url' && setting.setting_value) {
try {
logoUrl = JSON.parse(setting.setting_value);
} catch (e) {
logoUrl = setting.setting_value;
}
} else if (setting.setting_key === 'branding_company_name' && setting.setting_value) {
try {
companyName = JSON.parse(setting.setting_value);
} catch (e) {
companyName = setting.setting_value;
}
const val = setting.setting_value;
if (setting.setting_key === 'branding_logo_url' && val) {
try { logoUrl = JSON.parse(val); } catch (e) { logoUrl = val; }
} else if (setting.setting_key === 'branding_company_name' && val) {
try { companyName = JSON.parse(val); } catch (e) { companyName = val; }
} else if (setting.setting_key === 'email_primary_color' && val) {
try { primaryColor = JSON.parse(val); } catch (e) { primaryColor = val; }
} else if (setting.setting_key === 'email_secondary_color' && val) {
try { secondaryColor = JSON.parse(val); } catch (e) { secondaryColor = val; }
}
});
} catch (error) {
logger.error('Error fetching branding settings:', error);
}
// If no custom logo, use default PicPeak logo
const apiUrl = process.env.API_URL || 'http://localhost:3001';
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3005';
const logoFullUrl = logoUrl ? `${apiUrl}${logoUrl}` : `${frontendUrl}/picpeak-logo-transparent.png`;
const hoverColor = darkenColor(primaryColor, 0.15);
// Compile templates with Handlebars
const subjectTemplate = Handlebars.compile(subject);
const htmlTemplate = Handlebars.compile(htmlBody);
const textTemplate = Handlebars.compile(textBody);
// Process templates with processedVariables (includes formatted dates and security messages)
subject = subjectTemplate(processedVariables);
htmlBody = htmlTemplate(processedVariables);
textBody = textTemplate(processedVariables);
// Build full logo URL - ensure logoUrl is a valid non-empty string
const frontendUrl = (await getFrontendBaseUrl()) || 'http://localhost:3000';
const logoPath = (typeof logoUrl === 'string' && logoUrl.trim()) ? logoUrl : '/picpeak-logo-transparent.png';
const logoFullUrl = `${frontendUrl}${logoPath.startsWith('/') ? '' : '/'}${logoPath}`;
logger.debug('Email logo URL:', { frontendUrl, logoPath, logoFullUrl });
// Wrap HTML body in styled template
const styledHtmlBody = `
return `
<!DOCTYPE html>
<html lang="${language}">
<head>
@@ -233,7 +195,7 @@ async function processTemplate(template, variables, language = 'en') {
overflow: hidden;
}
.email-header {
background-color: #5C8762;
background-color: ${primaryColor};
padding: 30px;
text-align: center;
}
@@ -246,7 +208,7 @@ async function processTemplate(template, variables, language = 'en') {
padding: 40px 30px;
}
.email-content h2 {
color: #5C8762;
color: ${primaryColor};
margin-top: 0;
margin-bottom: 20px;
font-size: 24px;
@@ -267,7 +229,7 @@ async function processTemplate(template, variables, language = 'en') {
.button {
display: inline-block;
padding: 12px 30px;
background-color: #5C8762;
background-color: ${primaryColor};
color: white !important;
text-decoration: none;
border-radius: 5px;
@@ -275,10 +237,10 @@ async function processTemplate(template, variables, language = 'en') {
margin: 20px 0;
}
.button:hover {
background-color: #4a6f4f;
background-color: ${hoverColor};
}
.email-footer {
background-color: #f9f9f9;
background-color: ${secondaryColor};
padding: 30px;
text-align: center;
border-top: 1px solid #eee;
@@ -295,11 +257,11 @@ async function processTemplate(template, variables, language = 'en') {
margin: 5px 0;
}
a {
color: #5C8762;
color: ${primaryColor};
text-decoration: underline;
}
a:hover {
color: #4a6f4f;
color: ${hoverColor};
}
strong {
color: #333;
@@ -338,6 +300,168 @@ async function processTemplate(template, variables, language = 'en') {
</div>
</body>
</html>`;
}
// Process email template with variables
async function processTemplate(template, variables, language = 'en') {
// Import date formatter and text formatters
const { formatDate } = require('../utils/dateFormatter');
const { formatWelcomeMessage } = require('../utils/formatters');
// Get translation from email_template_translations table with fallback chain
let subject = '';
let htmlBody = '';
let textBody = '';
try {
// Try requested language first, then English, then any available
let translation = await db('email_template_translations')
.where({ template_id: template.id, language })
.first();
if (!translation && language !== 'en') {
translation = await db('email_template_translations')
.where({ template_id: template.id, language: 'en' })
.first();
}
if (!translation) {
translation = await db('email_template_translations')
.where({ template_id: template.id })
.first();
}
if (translation) {
subject = translation.subject || '';
htmlBody = translation.body_html || '';
textBody = translation.body_text || '';
}
} catch (error) {
logger.warn('email_template_translations table not available, falling back to columns:', error.message);
}
// Fallback to legacy column-based fields if no translation found
if (!subject && !htmlBody) {
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
const textField = language === 'de' ? 'body_text_de' : 'body_text_en';
subject = template[subjectField] || template.subject_en || template.subject || '';
htmlBody = template[htmlField] || template.body_html_en || template.body_html || '';
textBody = template[textField] || template.body_text_en || template.body_text || '';
}
// Process variables before template compilation
const processedVariables = { ...variables };
// Handle password security message
const passwordSecurityI18n = {
en: '(Not shown for security reasons)',
de: '(Aus Sicherheitsgründen nicht angezeigt)',
nl: '(Om veiligheidsredenen niet weergegeven)',
pt: '(Não exibido por motivos de segurança)',
ru: '(Не показано в целях безопасности)',
};
const noPasswordI18n = {
en: 'No password required',
de: 'Kein Passwort erforderlich',
nl: 'Geen wachtwoord vereist',
pt: 'Nenhuma senha necessária',
ru: 'Пароль не требуется',
};
if (processedVariables.gallery_password === '{{password_security_message}}') {
processedVariables.gallery_password = passwordSecurityI18n[language] || passwordSecurityI18n.en;
}
if (processedVariables.gallery_password === 'No password required') {
processedVariables.gallery_password = noPasswordI18n[language] || noPasswordI18n.en;
}
// Format dates if they exist
if (processedVariables.event_date) {
processedVariables.event_date = await formatDate(processedVariables.event_date, language);
}
if (processedVariables.expiry_date) {
processedVariables.expiry_date = await formatDate(processedVariables.expiry_date, language);
}
if (processedVariables.archive_date) {
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
}
if (processedVariables.expires_at) {
processedVariables.expires_at = await formatDate(processedVariables.expires_at, language);
}
// Format welcome message for HTML display (preserve line breaks)
if (processedVariables.welcome_message) {
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
}
// Safe template replacement (no code execution, only simple variable substitution)
function safeTemplateReplace(template, variables) {
return template.replace(/\{\{(\w+)\}\}/g, (match, key) =>
variables.hasOwnProperty(key) ? String(variables[key]) : match
);
}
subject = safeTemplateReplace(subject, processedVariables);
htmlBody = safeTemplateReplace(htmlBody, processedVariables);
textBody = safeTemplateReplace(textBody, processedVariables);
// Inject client access section if client_link is provided (#172)
if (processedVariables.client_link) {
const clientAccessI18n = {
de: {
label: 'Kundenzugang (Privat)',
desc: 'Fotos überprüfen und deren Sichtbarkeit festlegen, bevor die Galerie geteilt wird:',
link: 'Kundenzugang öffnen',
warning: 'Diesen Link nicht teilen — er ermöglicht das Ausblenden von Fotos in der Gästegalerie.',
},
ru: {
label: 'Доступ клиента (Личный)',
desc: 'Просмотрите и управляйте видимостью фотографий перед тем, как поделиться галереей с гостями:',
link: 'Открыть доступ клиента',
warning: 'Не делитесь этой ссылкой — она позволяет скрывать фотографии из гостевой галереи.',
},
nl: {
label: 'Klanttoegang (Privé)',
desc: 'Bekijk en beheer de zichtbaarheid van foto\'s voordat u deelt met gasten:',
link: 'Klanttoegang openen',
warning: 'Deel deze link niet — hiermee kunnen foto\'s worden verborgen in de gastengalerij.',
},
pt: {
label: 'Acesso do Cliente (Privado)',
desc: 'Revise e gerencie a visibilidade das fotos antes de compartilhar com os convidados:',
link: 'Abrir Acesso do Cliente',
warning: 'Não compartilhe este link — ele permite ocultar fotos da galeria de convidados.',
},
en: {
label: 'Client Access (Private)',
desc: 'Review and manage photo visibility before sharing with guests:',
link: 'Open Client Access',
warning: 'Do not share this link — it allows hiding photos from the guest gallery.',
},
};
const ci18n = clientAccessI18n[language] || clientAccessI18n.en;
const clientAccessLabel = ci18n.label;
const clientAccessDesc = ci18n.desc;
const clientAccessLink = ci18n.link;
const clientAccessWarning = ci18n.warning;
const pinLabel = 'PIN';
htmlBody += `
<div style="margin-top: 24px; padding: 20px; background: #fff3cd; border-left: 4px solid #ffc107; border-radius: 4px;">
<strong style="font-size: 15px;">&#128274; ${clientAccessLabel}</strong>
<p style="margin: 10px 0 8px;">${clientAccessDesc}</p>
<p style="margin: 8px 0;">
<a href="${processedVariables.client_link}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">${clientAccessLink}</a>
</p>
<p style="margin: 8px 0;">${pinLabel}: <strong>${processedVariables.client_password}</strong></p>
<p style="color: #856404; font-size: 12px; margin: 8px 0 0;">&#9888;&#65039; ${clientAccessWarning}</p>
</div>`;
}
// Wrap HTML body in styled template
const styledHtmlBody = await wrapEmailHtml(htmlBody, subject, language);
return { subject, htmlBody: styledHtmlBody, textBody };
}
@@ -558,5 +682,6 @@ module.exports = {
processEmailQueue,
queueEmail,
stopEmailQueueProcessor,
testEmailConnection
testEmailConnection,
wrapEmailHtml
};
+6 -2
View File
@@ -138,7 +138,9 @@ const createEvent = async (eventData) => {
show_feedback_to_guests,
// Upload settings
allow_user_uploads,
upload_category_id
upload_category_id,
// Photo cap
photo_cap
} = eventData;
const requirePassword = parseBooleanInput(require_password, true);
@@ -207,7 +209,9 @@ const createEvent = async (eventData) => {
show_feedback_to_guests: show_feedback_to_guests !== undefined ? formatBoolean(show_feedback_to_guests) : undefined,
// Upload settings
allow_user_uploads: allow_user_uploads !== undefined ? formatBoolean(allow_user_uploads) : undefined,
upload_category_id: upload_category_id || null
upload_category_id: upload_category_id || null,
// Photo cap
photo_cap: photo_cap || null
};
// Remove undefined values
+90 -9
View File
@@ -23,10 +23,15 @@ class FeedbackService {
allow_favorites: true,
require_name_email: false,
moderate_comments: true,
show_feedback_to_guests: true
show_feedback_to_guests: true,
identity_mode: 'simple'
};
}
// Back-compat: rows created before migration 078 have NULL identity_mode.
if (!settings.identity_mode) {
settings.identity_mode = 'simple';
}
return settings;
} catch (error) {
logger.error('Error getting feedback settings:', error);
@@ -73,23 +78,29 @@ class FeedbackService {
*/
async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) {
try {
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent } = feedbackData;
const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent, guest_id } = feedbackData;
// Validate feedback type
if (!['rating', 'like', 'comment', 'favorite'].includes(feedback_type)) {
throw new Error('Invalid feedback type');
}
// Check if similar feedback already exists (prevent duplicates)
// Check if similar feedback already exists (prevent duplicates).
// When a per-person guest_id is present, scope the check to that guest
// so two guests on the same device can independently like a photo.
if (feedback_type !== 'comment') {
const existing = await db('photo_feedback')
const duplicateQuery = db('photo_feedback')
.where({
photo_id: photoId,
event_id: eventId,
feedback_type,
guest_identifier: guestIdentifier
})
.first();
});
if (guest_id) {
duplicateQuery.where('guest_id', guest_id);
} else {
duplicateQuery.where('guest_identifier', guestIdentifier);
}
const existing = await duplicateQuery.first();
if (existing) {
if (feedback_type === 'rating' && rating !== existing.rating) {
@@ -129,6 +140,7 @@ class FeedbackService {
guest_name,
guest_email,
guest_identifier: guestIdentifier,
guest_id: guest_id || null,
ip_address,
user_agent,
is_approved: feedback_type !== 'comment' || !feedbackData.moderate_comments,
@@ -232,7 +244,7 @@ class FeedbackService {
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as like_count', ['like']),
db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as favorite_count', ['favorite']),
db.raw('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']),
db.raw('COUNT(DISTINCT guest_identifier) as feedback_count')
db.raw('COUNT(DISTINCT COALESCE(CAST(guest_id AS VARCHAR), guest_identifier)) as feedback_count')
)
.first();
@@ -460,6 +472,75 @@ class FeedbackService {
throw error;
}
}
/**
* Anonymize feedback belonging to a guest — sets guest_id to NULL on all
* their feedback rows and clears guest_name/guest_email for privacy, then
* recomputes denormalized photo counts on affected photos.
*
* Used by self-service "forget me" and admin guest deletion.
*/
async anonymizeGuestFeedback(guestId) {
try {
const affected = await db('photo_feedback')
.where('guest_id', guestId)
.select('photo_id');
const photoIds = [...new Set(affected.map((r) => r.photo_id))];
await db('photo_feedback')
.where('guest_id', guestId)
.update({
guest_id: null,
guest_name: null,
guest_email: null,
updated_at: new Date(),
});
for (const pid of photoIds) {
await this.updatePhotoFeedbackStats(pid);
}
return { anonymized: affected.length, photos: photoIds.length };
} catch (error) {
logger.error('Error anonymizing guest feedback:', error);
throw error;
}
}
/**
* Merge feedback rows from sourceGuestIds into keepGuestId. Used by admin
* guest merge and email-based identity recovery when a user re-registers.
* Recomputes denormalized counts on affected photos.
*/
async mergeGuestFeedback(keepGuestId, sourceGuestIds) {
try {
const sources = (sourceGuestIds || []).filter((id) => id && id !== keepGuestId);
if (sources.length === 0) {
return { merged: 0, photos: 0 };
}
const affected = await db('photo_feedback')
.whereIn('guest_id', sources)
.select('photo_id');
const photoIds = [...new Set(affected.map((r) => r.photo_id))];
await db('photo_feedback')
.whereIn('guest_id', sources)
.update({
guest_id: keepGuestId,
updated_at: new Date(),
});
for (const pid of photoIds) {
await this.updatePhotoFeedbackStats(pid);
}
return { merged: affected.length, photos: photoIds.length };
} catch (error) {
logger.error('Error merging guest feedback:', error);
throw error;
}
}
}
module.exports = new FeedbackService();
+1 -1
View File
@@ -5,7 +5,7 @@ const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
const logger = require('../utils/logger');
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
const { isVideoMimeType } = require('./videoProcessor');
const mime = require('mime-types');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -0,0 +1,131 @@
/**
* Guest identity recovery service (Phase 3.2).
*
* Sends a short-lived 6-digit verification code to a guest's email address
* so they can re-link their identity across devices. The code is stored as
* a bcrypt hash in `guest_verification_codes` with a 15-minute expiry.
*
* Uses the email transporter from emailProcessor — no new template row is
* needed; the email body is built inline so this works out of the box.
*/
const crypto = require('crypto');
const bcrypt = require('bcrypt');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { initializeTransporter, wrapEmailHtml } = require('./emailProcessor');
const CODE_TTL_MS = 15 * 60 * 1000;
const MAX_ATTEMPTS = 5;
function generateCode() {
// 6 digits, zero-padded.
return String(crypto.randomInt(0, 1_000_000)).padStart(6, '0');
}
async function createCode(eventId, email) {
const code = generateCode();
const codeHash = await bcrypt.hash(code, 10);
const expiresAt = new Date(Date.now() + CODE_TTL_MS);
// Invalidate any previous unconsumed codes for this email+event.
await db('guest_verification_codes')
.where({ event_id: eventId, email: email.toLowerCase() })
.whereNull('consumed_at')
.update({ consumed_at: db.fn.now() });
await db('guest_verification_codes').insert({
event_id: eventId,
email: email.toLowerCase(),
code_hash: codeHash,
expires_at: expiresAt,
});
return code;
}
async function sendRecoveryEmail(toEmail, code, eventName = 'your gallery') {
const transporter = await initializeTransporter();
if (!transporter) {
throw new Error('Email service not configured');
}
const config = await db('email_configs').first();
if (!config) {
throw new Error('Email configuration not found');
}
const subject = `Your verification code: ${code}`;
const htmlBody = `
<div style="font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 600px;">
<h2>Welcome back to ${eventName}</h2>
<p>Enter this code to recover your picks in the gallery:</p>
<div style="font-size: 32px; font-weight: bold; letter-spacing: 8px; background: #f5f5f5; padding: 20px; text-align: center; border-radius: 8px; margin: 20px 0;">
${code}
</div>
<p style="color: #666; font-size: 14px;">
This code expires in 15 minutes. If you did not request it, you can safely ignore this email.
</p>
</div>
`;
const styledHtml = await wrapEmailHtml(htmlBody, subject, 'en');
await transporter.sendMail({
from: `${config.from_name} <${config.from_email}>`,
to: toEmail,
subject,
html: styledHtml,
text: `Your verification code is ${code}. It expires in 15 minutes.`,
});
logger.info('Guest recovery code sent', { email: toEmail });
}
/**
* Verify a code. Returns true if valid + marks it consumed.
* Increments attempts on failure. Rejects after MAX_ATTEMPTS.
*/
async function verifyCode(eventId, email, submittedCode) {
const normalized = String(submittedCode || '').trim();
if (!/^\d{6}$/.test(normalized)) {
return { ok: false, reason: 'invalid_format' };
}
const row = await db('guest_verification_codes')
.where({ event_id: eventId, email: email.toLowerCase() })
.whereNull('consumed_at')
.andWhere('expires_at', '>', new Date())
.orderBy('created_at', 'desc')
.first();
if (!row) {
return { ok: false, reason: 'expired_or_missing' };
}
if (row.attempts >= MAX_ATTEMPTS) {
await db('guest_verification_codes').where('id', row.id).update({ consumed_at: db.fn.now() });
return { ok: false, reason: 'too_many_attempts' };
}
const matches = await bcrypt.compare(normalized, row.code_hash);
if (!matches) {
await db('guest_verification_codes')
.where('id', row.id)
.update({ attempts: row.attempts + 1 });
return { ok: false, reason: 'wrong_code' };
}
await db('guest_verification_codes')
.where('id', row.id)
.update({ consumed_at: db.fn.now() });
return { ok: true };
}
module.exports = {
createCode,
sendRecoveryEmail,
verifyCode,
CODE_TTL_MS,
MAX_ATTEMPTS,
};
+4 -22
View File
@@ -163,22 +163,13 @@ async function createRateLimiter() {
const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/);
return isAuthEndpoint ? currentConfig.authMaxRequests : currentConfig.maxRequests;
},
keyGenerator: (req) => {
// Use correct client IP when behind proxy
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
},
keyGenerator: (req) => req.ip,
skip: async (req) => {
const currentConfig = await getRateLimitSettings();
return shouldSkipRateLimit(req, currentConfig);
},
handler: (req, res) => {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
const clientIp = req.ip;
// Enhanced logging for production analysis
logger.warn('Rate limit exceeded', {
@@ -223,22 +214,13 @@ async function createAuthRateLimiter() {
return rateLimit({
windowMs: config.windowMinutes * 60 * 1000,
max: config.authMaxRequests,
keyGenerator: (req) => {
// Use correct client IP when behind proxy
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
},
keyGenerator: (req) => req.ip,
skip: async () => {
const currentConfig = await getRateLimitSettings();
return !currentConfig.enabled;
},
handler: (req, res) => {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
const clientIp = req.ip;
// Enhanced logging for auth failures
logger.warn('Auth rate limit exceeded', {
+3 -3
View File
@@ -30,10 +30,10 @@ async function verifyRecaptcha(token) {
return false;
}
// If no secret key configured, log warning but pass
// If no secret key configured, fail closed
if (!secretKey) {
console.warn('reCAPTCHA enabled but no secret key configured');
return true;
console.warn('reCAPTCHA enabled but no secret key configured — blocking request');
return false;
}
try {
+4 -2
View File
@@ -1,6 +1,7 @@
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { extractShareToken, isPotentialShareToken, buildSharePath } = require('../utils/shareLinkUtils');
const { getFrontendBaseUrl } = require('../utils/frontendUrl');
const SETTING_KEY = 'general_short_gallery_urls';
const CACHE_TTL_MS = 60_000;
@@ -87,7 +88,7 @@ const buildShareLinkVariants = async ({ slug, shareToken }) => {
const shortEnabled = await isShortGalleryUrlsEnabled();
const sharePath = buildSharePath(slug, shareToken, shortEnabled);
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
const frontendBase = await getFrontendBaseUrl();
const shareUrl = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
return {
@@ -112,7 +113,8 @@ const getEventShareToken = (event) => {
const ACTIVE_EVENT_FILTER = {
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
is_archived: formatBoolean(false),
is_draft: formatBoolean(false)
};
const resolveShareIdentifier = async (identifier) => {
+10
View File
@@ -85,6 +85,16 @@ class S3StorageAdapter extends stream.EventEmitter {
endpoint = this.config.sslEnabled ? `https://${endpoint}` : `http://${endpoint}`;
}
// SSRF protection: block private/internal S3 endpoints in production
// Local endpoints (e.g. MinIO on localhost) are allowed in development
if (process.env.NODE_ENV === 'production') {
const { validateExternalUrl } = require('../../utils/networkValidation');
const urlCheck = validateExternalUrl(endpoint);
if (!urlCheck.valid) {
throw new Error(`Invalid S3 endpoint: ${urlCheck.error}`);
}
}
s3Config.endpoint = endpoint;
// For S3-compatible services with custom endpoints, force path style
+35 -2
View File
@@ -18,7 +18,7 @@ const { ConflictError, NotFoundError, ValidationError } = require('../utils/erro
* @param {object} params - { email, roleId, invitedById }
* @returns {Promise<object>} Created invitation details
*/
async function createInvitation({ email, roleId, invitedById }) {
async function createInvitation({ email, roleId, invitedById, inviterRoleName }) {
// Check if email already exists
const existingUser = await db('admin_users').where('email', email).first();
if (existingUser) {
@@ -42,6 +42,11 @@ async function createInvitation({ email, roleId, invitedById }) {
throw new NotFoundError('Role', roleId);
}
// Role hierarchy: only super_admin can invite super_admin
if (role.name === 'super_admin' && inviterRoleName !== 'super_admin') {
throw new ValidationError('Only Super Admins can invite new Super Admins');
}
// Generate secure invitation token (64 characters hex = 32 bytes)
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
@@ -213,7 +218,7 @@ async function getAdminUserById(id) {
* @param {number} updatedById - ID of user making the update
* @returns {Promise<object>} Updated user
*/
async function updateAdminUser(id, updates, updatedById) {
async function updateAdminUser(id, updates, updatedById, requestingAdmin = {}) {
const user = await db('admin_users').where('id', id).first();
if (!user) {
throw new NotFoundError('Admin user', id);
@@ -248,6 +253,34 @@ async function updateAdminUser(id, updates, updatedById) {
if (!role) {
throw new NotFoundError('Role', updates.role_id);
}
// Role hierarchy enforcement
const superAdminRole = await db('roles').where('name', 'super_admin').first();
const isSuperAdmin = requestingAdmin.roleName === 'super_admin';
// Only super_admin can assign super_admin role
if (superAdminRole && role.id === superAdminRole.id && !isSuperAdmin) {
throw new ValidationError('Only Super Admins can assign the Super Admin role');
}
// Prevent self-role-update
if (id === updatedById) {
throw new ValidationError('Cannot change your own role');
}
// Prevent downgrading the last super_admin
if (superAdminRole && user.role_id === superAdminRole.id && role.id !== superAdminRole.id) {
const superAdminCount = await db('admin_users')
.where('role_id', superAdminRole.id)
.where('is_active', formatBoolean(true))
.count('id as count')
.first();
if (Number(superAdminCount?.count) <= 1) {
throw new ValidationError('Cannot demote the last Super Admin');
}
}
allowedUpdates.role_id = updates.role_id;
}
+1 -1
View File
@@ -261,7 +261,7 @@ async function checkAccountLockout(identifier, ipAddress) {
return { isLocked: false };
} catch (error) {
logger.error('Error checking account lockout:', error);
return { isLocked: false }; // Fail open to avoid locking users out due to errors
return { isLocked: true, remainingTime: 300 }; // Fail closed on DB error
}
}
+4 -3
View File
@@ -29,8 +29,8 @@ const FORBIDDEN_PATTERNS = [
/on\w+\s*=/gi, // onclick=, onload=, etc.
];
// Pattern for external URLs (block external, allow data: for images)
const EXTERNAL_URL_PATTERN = /url\s*\(\s*["']?(?!data:image)/gi;
// Pattern for external URLs (block external, allow only safe raster data: images)
const EXTERNAL_URL_PATTERN = /url\s*\(\s*["']?(?!data:image\/(?:jpeg|jpg|png|gif|webp))/gi;
// Maximum CSS size in bytes (100KB)
const MAX_CSS_SIZE = 100 * 1024;
@@ -50,7 +50,8 @@ function sanitizeCss(css) {
/@charset[^;]+;?/gi,
/expression\s*\([^)]*\)/gi,
/url\s*\(\s*(['"])\s*javascript:[^)]*\)/gi,
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi
/url\s*\(\s*(['"])\s*data:text\/javascript[^)]*\)/gi,
/url\s*\(\s*(['"]?)\s*data:image\/svg\+xml[^)]*\)/gi
];
disallowedPatterns.forEach((pattern) => {
+2
View File
@@ -54,6 +54,8 @@ async function formatDate(date, language = 'en') {
let locale = dateConfig.locale || 'en-GB';
if (language === 'de') {
locale = 'de-DE';
} else if (language === 'pt') {
locale = 'pt-BR';
} else if (language === 'en' && dateConfig.format === 'MM/DD/YYYY') {
locale = 'en-US';
}
+3 -1
View File
@@ -184,7 +184,9 @@ const validateFeedbackSettings = [
body('allow_favorites').optional().isBoolean(),
body('require_name_email').optional().isBoolean(),
body('moderate_comments').optional().isBoolean(),
body('show_feedback_to_guests').optional().isBoolean()
body('show_feedback_to_guests').optional().isBoolean(),
body('identity_mode').optional().isIn(['simple', 'guest'])
.withMessage('identity_mode must be "simple" or "guest"')
];
/**
+27
View File
@@ -0,0 +1,27 @@
const { db } = require('../database/db');
const getFrontendBaseUrl = async () => {
let base = (process.env.FRONTEND_URL || '').trim().replace(/\/$/, '');
if (base) return base;
try {
const setting = await db('app_settings')
.where('setting_key', 'general_site_url')
.select('setting_value')
.first();
if (setting && setting.setting_value) {
let val = setting.setting_value;
if (typeof val === 'string') {
try { val = JSON.parse(val); } catch (_) {}
}
if (typeof val === 'string' && val.trim()) {
base = val.trim().replace(/\/$/, '');
}
}
} catch (_) {}
return base;
};
module.exports = { getFrontendBaseUrl };
+97
View File
@@ -0,0 +1,97 @@
const { URL } = require('url');
const net = require('net');
/**
* Check if a hostname or IP resolves to a private/internal network address.
* Blocks SSRF attempts targeting internal infrastructure.
*/
function isPrivateIP(hostname) {
if (!hostname || typeof hostname !== 'string') return true;
const lower = hostname.toLowerCase().trim();
// Block known metadata / loopback hostnames
const blockedHostnames = [
'localhost',
'metadata.google.internal',
'metadata.google',
'169.254.169.254',
'0.0.0.0',
'::1',
'[::1]',
];
if (blockedHostnames.includes(lower)) return true;
// If it's an IP address, check ranges directly
if (net.isIPv4(lower)) {
return isPrivateIPv4(lower);
}
// IPv6 checks
if (net.isIPv6(lower) || lower.startsWith('[')) {
const cleanIp = lower.replace(/^\[|\]$/g, '');
return isPrivateIPv6(cleanIp);
}
// Hostname patterns that resolve to internal services
if (lower.endsWith('.internal') || lower.endsWith('.local') || lower.endsWith('.localhost')) {
return true;
}
return false;
}
function isPrivateIPv4(ip) {
const parts = ip.split('.').map(Number);
if (parts.length !== 4 || parts.some(p => isNaN(p))) return true;
const [a, b] = parts;
// 127.0.0.0/8 — loopback
if (a === 127) return true;
// 10.0.0.0/8 — private
if (a === 10) return true;
// 172.16.0.0/12 — private
if (a === 172 && b >= 16 && b <= 31) return true;
// 192.168.0.0/16 — private
if (a === 192 && b === 168) return true;
// 169.254.0.0/16 — link-local
if (a === 169 && b === 254) return true;
// 0.0.0.0/8
if (a === 0) return true;
return false;
}
function isPrivateIPv6(ip) {
const lower = ip.toLowerCase();
// ::1 loopback
if (lower === '::1' || lower === '0000:0000:0000:0000:0000:0000:0000:0001') return true;
// fc00::/7 — unique local
if (lower.startsWith('fc') || lower.startsWith('fd')) return true;
// fe80::/10 — link-local
if (lower.startsWith('fe80')) return true;
// :: unspecified
if (lower === '::') return true;
return false;
}
/**
* Validate a URL string, rejecting private/internal targets.
* @param {string} urlString - URL to validate
* @returns {{ valid: boolean, error?: string }}
*/
function validateExternalUrl(urlString) {
try {
const parsed = new URL(urlString);
if (isPrivateIP(parsed.hostname)) {
return { valid: false, error: 'URL points to a private or internal network address' };
}
return { valid: true };
} catch {
return { valid: false, error: 'Invalid URL format' };
}
}
module.exports = { isPrivateIP, validateExternalUrl };
+11 -11
View File
@@ -213,17 +213,17 @@ async function validatePasswordInContext(password, context, userData = {}) {
// Base validation with gallery-specific options
const result = validatePassword(password, galleryOptions);
// Override validation for common date formats
// Allow passwords like "04.07.2025", "04/07/2025", "04-07-2025"
const datePattern = /^\d{1,2}[.\/-]\d{1,2}[.\/-]\d{4}$/;
if (datePattern.test(password)) {
// Date format is valid for gallery passwords
return {
valid: true,
errors: [],
score: 2,
feedback: {}
};
// Only allow date-format passwords when complexity is 'simple'
if (complexityLevel === 'simple') {
const datePattern = /^\d{1,2}[.\/-]\d{1,2}[.\/-]\d{4}$/;
if (datePattern.test(password)) {
return {
valid: true,
errors: [],
score: 2,
feedback: {}
};
}
}
// Additional gallery-specific checks
+2 -22
View File
@@ -9,28 +9,8 @@ function getClientIp(req) {
if (!req) {
return '';
}
const forwardedFor = req.headers['x-forwarded-for'];
if (typeof forwardedFor === 'string' && forwardedFor.length > 0) {
const [firstIp] = forwardedFor.split(',').map(part => part.trim()).filter(Boolean);
if (firstIp) {
return firstIp;
}
} else if (Array.isArray(forwardedFor) && forwardedFor.length > 0) {
const [firstIp] = forwardedFor;
if (firstIp) {
return firstIp.trim();
}
}
return (
req.ip ||
req.connection?.remoteAddress ||
req.socket?.remoteAddress ||
req.connection?.socket?.remoteAddress ||
''
);
// Use req.ip which respects Express 'trust proxy' setting
return req.ip || req.connection?.remoteAddress || '';
}
module.exports = { getClientIp };
-5
View File
@@ -57,11 +57,6 @@ async function isTokenRevoked(decodedToken) {
const revoked = await db('revoked_tokens')
.where('token_id', tokenId)
.orWhere((builder) => {
builder
.where('user_id', decodedToken.id)
.where('revoked_at', '<=', new Date(decodedToken.iat * 1000).toISOString());
})
.first();
return !!revoked;
+98 -18
View File
@@ -1,24 +1,53 @@
const ADMIN_COOKIE_NAME = 'admin_token';
const GALLERY_COOKIE_NAME = 'gallery_token';
const GALLERY_COOKIE_PREFIX = 'gallery_token_';
const GUEST_COOKIE_PREFIX = 'guest_token_';
const DEFAULT_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
const secureCookie = (() => {
if (typeof process.env.COOKIE_SECURE === 'string') {
return process.env.COOKIE_SECURE.toLowerCase() === 'true';
}
// Default to false so native HTTP installs stay functional. Operators can
// opt-in via COOKIE_SECURE=true when serving behind HTTPS.
return false;
/**
* Cookie "Secure" flag mode:
* - true → always set Secure (HTTPS-only)
* - false → never set Secure (allow plain HTTP)
* - 'auto' → decide per-request based on req.secure (X-Forwarded-Proto
* via Express `trust proxy`). Useful when the same deployment
* is reachable over both HTTPS (via reverse proxy) and LAN HTTP.
*
* Default: follows NODE_ENV (production → true, dev → false) — unchanged
* from previous behavior. Users who want the auto mode must opt in with
* COOKIE_SECURE=auto in their .env.
*/
const secureCookieMode = (() => {
const raw = typeof process.env.COOKIE_SECURE === 'string'
? process.env.COOKIE_SECURE.toLowerCase()
: '';
if (raw === 'auto') return 'auto';
if (raw === 'true') return true;
if (raw === 'false') return false;
// No env var set → legacy default
return process.env.NODE_ENV === 'production';
})();
const sameSiteDefault = process.env.COOKIE_SAMESITE || 'Lax';
const cookieDomain = process.env.COOKIE_DOMAIN;
function buildCookieBaseOptions() {
/**
* Resolve the Secure flag for a specific response. When in 'auto' mode,
* checks req.secure (which reflects the X-Forwarded-Proto header when the
* proxy is in the trust list set by `app.set('trust proxy', ...)`). When
* called without a `res`, falls back to false — this only happens in code
* paths that don't yet have a response object, which we avoid.
*/
function resolveSecureFlag(res) {
if (secureCookieMode === 'auto') {
return Boolean(res?.req?.secure);
}
return secureCookieMode;
}
function buildCookieBaseOptions(res) {
const options = {
httpOnly: true,
secure: secureCookie,
secure: resolveSecureFlag(res),
sameSite: sameSiteDefault,
path: '/',
};
@@ -30,29 +59,52 @@ function buildCookieBaseOptions() {
return options;
}
function buildCookieOptionsWithExpiry(maxAgeMs = DEFAULT_MAX_AGE_MS) {
function buildCookieOptionsWithExpiry(res, maxAgeMs = DEFAULT_MAX_AGE_MS) {
return {
...buildCookieBaseOptions(),
...buildCookieBaseOptions(res),
maxAge: maxAgeMs,
};
}
/**
* Options for clearing a cookie. We deliberately omit `secure` here: when
* a cookie was set over HTTPS (Secure=true) and we later need to clear it
* from a response on an HTTP path (or vice versa, in mixed-protocol
* deployments with COOKIE_SECURE=auto), specifying `secure` in the clear
* options causes some browsers to reject the Set-Cookie delete header.
* Browsers match the cookie by (name, domain, path) for deletion, so
* leaving Secure off produces a header the browser always accepts.
*/
function buildClearCookieOptions() {
const options = {
httpOnly: true,
sameSite: sameSiteDefault,
path: '/',
};
if (cookieDomain) {
options.domain = cookieDomain;
}
return options;
}
function sanitizeSlugForCookie(slug = '') {
return String(slug).replace(/[^A-Za-z0-9_-]/g, '_');
}
function setAdminAuthCookie(res, token) {
if (!token) return;
res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry());
res.cookie(ADMIN_COOKIE_NAME, token, buildCookieOptionsWithExpiry(res));
}
function clearAdminAuthCookie(res) {
res.clearCookie(ADMIN_COOKIE_NAME, buildCookieBaseOptions());
res.clearCookie(ADMIN_COOKIE_NAME, buildClearCookieOptions());
}
function setGalleryAuthCookies(res, token, slug) {
if (!token) return;
const options = buildCookieOptionsWithExpiry();
const options = buildCookieOptionsWithExpiry(res);
res.cookie(GALLERY_COOKIE_NAME, token, options);
if (slug) {
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
@@ -61,18 +113,18 @@ function setGalleryAuthCookies(res, token, slug) {
}
function clearGalleryAuthCookies(res, slug) {
const baseOptions = buildCookieBaseOptions();
res.clearCookie(GALLERY_COOKIE_NAME, baseOptions);
const clearOptions = buildClearCookieOptions();
res.clearCookie(GALLERY_COOKIE_NAME, clearOptions);
const cookies = res.req?.cookies || {};
if (slug) {
const cookieName = `${GALLERY_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
res.clearCookie(cookieName, baseOptions);
res.clearCookie(cookieName, clearOptions);
} else {
Object.keys(cookies).forEach((name) => {
if (name.startsWith(GALLERY_COOKIE_PREFIX)) {
res.clearCookie(name, baseOptions);
res.clearCookie(name, clearOptions);
}
});
}
@@ -115,10 +167,37 @@ function getGalleryTokenFromRequest(req, slug) {
return null;
}
function getGuestTokenFromRequest(req, slug) {
// Primary transport: custom header (set by frontend axios interceptor).
const headerToken = req.headers?.['x-guest-token'];
if (headerToken) {
return headerToken;
}
if (!req.cookies) {
return null;
}
if (slug) {
const cookieName = `${GUEST_COOKIE_PREFIX}${sanitizeSlugForCookie(slug)}`;
if (req.cookies[cookieName]) {
return req.cookies[cookieName];
}
}
const prefixed = Object.keys(req.cookies).find((name) => name.startsWith(GUEST_COOKIE_PREFIX));
if (prefixed) {
return req.cookies[prefixed];
}
return null;
}
module.exports = {
ADMIN_COOKIE_NAME,
GALLERY_COOKIE_NAME,
GALLERY_COOKIE_PREFIX,
GUEST_COOKIE_PREFIX,
sanitizeSlugForCookie,
setAdminAuthCookie,
clearAdminAuthCookie,
@@ -126,4 +205,5 @@ module.exports = {
clearGalleryAuthCookies,
getAdminTokenFromRequest,
getGalleryTokenFromRequest,
getGuestTokenFromRequest,
};
+10
View File
@@ -98,6 +98,16 @@ services:
networks:
- picpeak-network
mailhog:
image: mailhog/mailhog:latest
container_name: picpeak-mailhog
restart: unless-stopped
ports:
- "${MAILHOG_SMTP_PORT:-1025}:1025"
- "${MAILHOG_UI_PORT:-8025}:8025"
networks:
- picpeak-network
frontend:
build:
context: ./frontend
+10
View File
@@ -37,6 +37,11 @@ server {
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
# Re-apply security headers (add_header in location block overrides server-level)
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
}
# Cache index.html with revalidation
@@ -44,6 +49,11 @@ server {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
# Re-apply security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' https://www.google.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https:; img-src 'self' data: https: blob:; connect-src 'self' https://www.google.com https://www.gstatic.com; font-src 'self' https: data:; object-src 'none'; media-src 'self'; frame-src 'self' https://www.google.com" always;
}
# API proxy
+14 -11
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "2.5.0",
"version": "3.24.0-beta.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "2.5.0",
"version": "3.24.0-beta.0",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
@@ -20,7 +20,7 @@
"@types/dompurify": "^3.0.5",
"@types/lodash": "^4.17.20",
"@types/react-google-recaptcha": "^2.1.9",
"axios": "^1.12.2",
"axios": "1.14.0",
"clsx": "^2.0.0",
"date-fns": "4.1.0",
"dompurify": "^3.2.6",
@@ -3020,14 +3020,14 @@
}
},
"node_modules/axios": {
"version": "1.13.6",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz",
"integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==",
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.14.0.tgz",
"integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"form-data": "^4.0.5",
"proxy-from-env": "^1.1.0"
"proxy-from-env": "^2.1.0"
}
},
"node_modules/balanced-match": {
@@ -5751,10 +5751,13 @@
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
"integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/punycode": {
"version": "2.3.1",
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "2.6.1",
"version": "3.28.2-beta.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -24,7 +24,7 @@
"@types/dompurify": "^3.0.5",
"@types/lodash": "^4.17.20",
"@types/react-google-recaptcha": "^2.1.9",
"axios": "^1.12.2",
"axios": "1.14.0",
"clsx": "^2.0.0",
"date-fns": "4.1.0",
"dompurify": "^3.2.6",
+6
View File
@@ -8,6 +8,7 @@ import { analyticsService } from './services/analytics.service';
import { GalleryAuthProvider, MaintenanceProvider } from './contexts';
import { ThemeProvider } from './contexts/ThemeContext';
import { GalleryPage } from './pages/GalleryPage';
import { ClientAccessPage } from './pages/ClientAccessPage';
import { PreviewPage } from './pages/gallery/PreviewPage';
import { LegalPage } from './pages/public/LegalPage';
import {
@@ -121,6 +122,11 @@ function App() {
<Routes>
{/* Public gallery routes */}
<Route path="/gallery/preview" element={<PreviewPage />} />
<Route path="/gallery/:slug/client-access" element={
<GalleryAuthProvider>
<ClientAccessPage />
</GalleryAuthProvider>
} />
<Route path="/gallery/:slug/:token?" element={
<GalleryAuthProvider>
<GalleryPage />
@@ -0,0 +1,205 @@
import React, { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { X, Heart, Bookmark, Star, MessageCircle } from 'lucide-react';
import { Loading } from '../common';
import { guestsService, AdminGuest } from '../../services/guests.service';
import { AuthenticatedImage } from '../common/AuthenticatedImage';
import { buildResourceUrl } from '../../utils/url';
interface AdminGuestDetailProps {
eventId: number;
guest: AdminGuest;
onClose: () => void;
}
type Tab = 'all' | 'liked' | 'favorited' | 'rated' | 'commented';
export const AdminGuestDetail: React.FC<AdminGuestDetailProps> = ({ eventId, guest, onClose }) => {
const { t } = useTranslation();
const [tab, setTab] = useState<Tab>('all');
const { data, isLoading } = useQuery({
queryKey: ['admin-guest-detail', eventId, guest.id],
queryFn: () => guestsService.getGuestDetail(eventId, guest.id),
});
const selections = data?.selections;
const liked = selections?.liked || [];
const favorited = selections?.favorited || [];
const rated = selections?.rated || [];
const commented = selections?.commented || [];
// "all" view combines the three visual selection types.
type GridItem = { photo: { id: number; filename: string; thumbnail_url: string }; badges: string[] };
const allItems: GridItem[] = [];
const seen = new Map<number, GridItem>();
const add = (photo: { id: number; filename: string; thumbnail_url: string }, badge: string) => {
if (!seen.has(photo.id)) {
const item: GridItem = { photo, badges: [badge] };
seen.set(photo.id, item);
allItems.push(item);
} else {
seen.get(photo.id)!.badges.push(badge);
}
};
liked.forEach((p) => add(p, 'like'));
favorited.forEach((p) => add(p, 'favorite'));
rated.forEach((r) => add(r.photo, 'rating'));
const visibleItems: GridItem[] =
tab === 'all'
? allItems
: tab === 'liked'
? liked.map((p) => ({ photo: p, badges: ['like'] }))
: tab === 'favorited'
? favorited.map((p) => ({ photo: p, badges: ['favorite'] }))
: tab === 'rated'
? rated.map((r) => ({ photo: r.photo, badges: [`${r.rating}`] }))
: [];
return (
<div className="fixed inset-0 z-40 flex items-start justify-center overflow-y-auto p-4 pt-16">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{guest.name}</h2>
{guest.email && (
<p className="text-sm text-neutral-500 dark:text-neutral-400">{guest.email}</p>
)}
</div>
<button
type="button"
onClick={onClose}
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
>
<X className="w-5 h-5" />
</button>
</div>
{isLoading ? (
<div className="p-8">
<Loading size="lg" text={t('admin.guests.loadingDetail', 'Loading selections...')} />
</div>
) : (
<div className="overflow-y-auto p-4">
{/* Stats */}
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{liked.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<Heart className="w-3 h-3" />
{t('admin.guests.columns.likes', 'Likes')}
</div>
</div>
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{favorited.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<Bookmark className="w-3 h-3" />
{t('admin.guests.columns.favorites', 'Favorites')}
</div>
</div>
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{rated.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<Star className="w-3 h-3" />
{t('admin.guests.columns.ratings', 'Ratings')}
</div>
</div>
<div className="p-3 bg-neutral-50 dark:bg-neutral-800 rounded text-center">
<div className="text-2xl font-semibold text-neutral-900 dark:text-neutral-100">
{commented.length}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 flex items-center justify-center gap-1">
<MessageCircle className="w-3 h-3" />
{t('admin.guests.columns.comments', 'Comments')}
</div>
</div>
</div>
{/* Tabs */}
<div className="flex gap-1 border-b border-neutral-200 dark:border-neutral-700 mb-4">
{(['all', 'liked', 'favorited', 'rated', 'commented'] as const).map((k) => (
<button
key={k}
type="button"
onClick={() => setTab(k)}
className={`px-3 py-2 text-sm font-medium border-b-2 transition ${
tab === k
? 'border-primary-500 text-primary-600 dark:text-primary-400'
: 'border-transparent text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100'
}`}
>
{t(`admin.guests.detail.${k}`, k)}
</button>
))}
</div>
{/* Content */}
{tab === 'commented' ? (
commented.length === 0 ? (
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
{t('admin.guests.detail.noComments', 'No comments')}
</div>
) : (
<div className="space-y-3">
{commented.map((c, idx) => (
<div key={idx} className="flex gap-3 p-3 bg-neutral-50 dark:bg-neutral-800 rounded">
<AuthenticatedImage
src={buildResourceUrl(c.photo.thumbnail_url)}
alt={c.photo.filename}
className="w-16 h-16 object-cover rounded flex-shrink-0"
/>
<div className="flex-1">
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{c.photo.filename} · {new Date(c.created_at).toLocaleString()}
</div>
<p className="text-sm text-neutral-900 dark:text-neutral-100 mt-1">{c.comment}</p>
</div>
</div>
))}
</div>
)
) : visibleItems.length === 0 ? (
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-8">
{t('admin.guests.detail.empty', 'No selections in this category')}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-2">
{visibleItems.map((item) => (
<div key={item.photo.id} className="relative group">
<AuthenticatedImage
src={buildResourceUrl(item.photo.thumbnail_url)}
alt={item.photo.filename}
className="w-full aspect-square object-cover rounded"
/>
<div className="absolute top-1 right-1 flex gap-1">
{item.badges.map((b, i) => (
<span
key={i}
className="bg-black/60 text-white text-xs px-1.5 py-0.5 rounded"
>
{b === 'like' ? '♥' : b === 'favorite' ? '★' : b}
</span>
))}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity text-white text-xs p-2 rounded-b">
{item.photo.filename}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,344 @@
import React, { useState } from 'react';
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Trash2, Eye, Download, UserPlus, Grid3x3, List } from 'lucide-react';
import { Card, Button, Loading } from '../common';
import { guestsService, AdminGuest } from '../../services/guests.service';
import { AdminGuestDetail } from './AdminGuestDetail';
import { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
import { GuestInviteDialog } from './GuestInviteDialog';
import { toast } from 'react-toastify';
interface AdminGuestsListProps {
eventId: number;
eventName?: string;
}
type View = 'list' | 'aggregate';
export const AdminGuestsList: React.FC<AdminGuestsListProps> = ({ eventId, eventName }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [view, setView] = useState<View>('list');
const [selectedGuest, setSelectedGuest] = useState<AdminGuest | null>(null);
const [mergeMode, setMergeMode] = useState(false);
const [mergeSelection, setMergeSelection] = useState<number[]>([]);
const [inviteDialogOpen, setInviteDialogOpen] = useState(false);
const { data, isLoading, refetch } = useQuery({
queryKey: ['admin-guests', eventId],
queryFn: () => guestsService.getEventGuests(eventId),
});
const deleteMutation = useMutation({
mutationFn: (guestId: number) => guestsService.deleteGuest(eventId, guestId),
onSuccess: () => {
toast.success(t('admin.guests.deletedToast', 'Guest removed'));
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
},
onError: () => toast.error(t('admin.guests.deletedError', 'Failed to remove guest')),
});
const mergeMutation = useMutation({
mutationFn: ({ keepId, mergeIds }: { keepId: number; mergeIds: number[] }) =>
guestsService.mergeGuests(eventId, keepId, mergeIds),
onSuccess: () => {
toast.success(t('admin.guests.mergedToast', 'Guests merged'));
setMergeMode(false);
setMergeSelection([]);
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
},
onError: () => toast.error(t('admin.guests.mergedError', 'Failed to merge guests')),
});
const handleDelete = (guest: AdminGuest) => {
if (window.confirm(t('admin.guests.forgetGuestConfirm', 'Remove this guest? Their picks will be anonymized but kept in aggregate totals.'))) {
deleteMutation.mutate(guest.id);
}
};
const handleExport = async (guest: AdminGuest, format: 'txt' | 'csv' | 'json') => {
try {
const blob = await guestsService.exportGuest(eventId, guest.id, format);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${guest.name.replace(/[^a-zA-Z0-9_-]/g, '_')}.${format}`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch {
toast.error(t('admin.guests.exportError', 'Export failed'));
}
};
const handleExportAll = async (format: 'txt' | 'csv' | 'json') => {
try {
const blob = await guestsService.exportAllGuests(eventId, format);
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `event-${eventId}-guests.zip`;
document.body.appendChild(a);
a.click();
a.remove();
window.URL.revokeObjectURL(url);
} catch {
toast.error(t('admin.guests.exportError', 'Export failed'));
}
};
const toggleMergeSelection = (id: number) => {
setMergeSelection((prev) =>
prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id]
);
};
const performMerge = () => {
if (mergeSelection.length < 2) {
toast.warning(t('admin.guests.mergeSelectAtLeastTwo', 'Select at least 2 guests to merge'));
return;
}
const [keepId, ...mergeIds] = mergeSelection;
const keepName = data?.guests.find((g) => g.id === keepId)?.name;
const confirmMsg = t(
'admin.guests.mergeConfirm',
'Merge {{count}} guests into {{name}}? This cannot be undone.',
{ count: mergeSelection.length, name: keepName || '#' + keepId }
);
if (window.confirm(confirmMsg)) {
mergeMutation.mutate({ keepId, mergeIds });
}
};
if (isLoading) {
return <Loading size="lg" text={t('admin.guests.loading', 'Loading guests...')} />;
}
const guests = data?.guests || [];
if (view === 'aggregate') {
return (
<div>
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => setView('list')} leftIcon={<List className="w-4 h-4" />}>
{t('admin.guests.backToList', 'Back to list')}
</Button>
</div>
</div>
<GuestSelectionsAggregate eventId={eventId} />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between flex-wrap gap-2">
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('admin.guests.title', 'Guests')} ({guests.length})
</h3>
<div className="flex items-center gap-2">
{mergeMode ? (
<>
<span className="text-sm text-neutral-600 dark:text-neutral-400">
{t('admin.guests.mergeSelected', '{{count}} selected', { count: mergeSelection.length })}
</span>
<Button variant="primary" size="sm" onClick={performMerge} disabled={mergeSelection.length < 2}>
{t('admin.guests.mergeNow', 'Merge selected')}
</Button>
<Button variant="ghost" size="sm" onClick={() => { setMergeMode(false); setMergeSelection([]); }}>
{t('common.cancel', 'Cancel')}
</Button>
</>
) : (
<>
<Button
variant="outline"
size="sm"
leftIcon={<UserPlus className="w-4 h-4" />}
onClick={() => setInviteDialogOpen(true)}
>
{t('admin.guests.createInvite', 'Create invite')}
</Button>
<Button
variant="outline"
size="sm"
leftIcon={<Grid3x3 className="w-4 h-4" />}
onClick={() => setView('aggregate')}
>
{t('admin.guests.aggregateView', 'By popularity')}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setMergeMode(true)}
disabled={guests.length < 2}
>
{t('admin.guests.mergeMode', 'Merge')}
</Button>
<div className="relative group">
<Button variant="outline" size="sm" leftIcon={<Download className="w-4 h-4" />}>
{t('admin.guests.exportAll', 'Export all')}
</Button>
<div className="absolute right-0 top-full mt-1 hidden group-hover:block bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded shadow-lg z-10 min-w-[120px]">
{(['csv', 'txt', 'json'] as const).map((fmt) => (
<button
key={fmt}
onClick={() => handleExportAll(fmt)}
className="block w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
>
{fmt.toUpperCase()}
</button>
))}
</div>
</div>
</>
)}
</div>
</div>
{guests.length === 0 ? (
<Card>
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
{t('admin.guests.empty', 'No guests have registered yet.')}
</div>
</Card>
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-neutral-50 dark:bg-neutral-800 border-b border-neutral-200 dark:border-neutral-700">
<tr>
{mergeMode && <th className="px-4 py-3 w-8" />}
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.name', 'Name')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.email', 'Email')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.likes', 'Likes')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.favorites', 'Favorites')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.comments', 'Comments')}
</th>
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.ratings', 'Ratings')}
</th>
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-600 dark:text-neutral-400 uppercase">
{t('admin.guests.columns.lastSeen', 'Last seen')}
</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-neutral-200 dark:divide-neutral-700">
{guests.map((guest) => (
<tr key={guest.id} className="hover:bg-neutral-50 dark:hover:bg-neutral-800">
{mergeMode && (
<td className="px-4 py-3">
<input
type="checkbox"
checked={mergeSelection.includes(guest.id)}
onChange={() => toggleMergeSelection(guest.id)}
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
</td>
)}
<td className="px-4 py-3 font-medium text-neutral-900 dark:text-neutral-100">
{guest.name}
{guest.email_verified_at && (
<span className="ml-2 text-xs text-green-600"></span>
)}
</td>
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
{guest.email || '—'}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.likes}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.favorites}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.comments}
</td>
<td className="px-4 py-3 text-right text-sm text-neutral-900 dark:text-neutral-100">
{guest.stats.ratings}
</td>
<td className="px-4 py-3 text-sm text-neutral-600 dark:text-neutral-400">
{new Date(guest.last_seen_at).toLocaleDateString()}
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => setSelectedGuest(guest)}
className="p-1 text-neutral-500 hover:text-primary-600"
title={t('admin.guests.view', 'View details')}
>
<Eye className="w-4 h-4" />
</button>
<div className="relative group">
<button
type="button"
className="p-1 text-neutral-500 hover:text-primary-600"
title={t('admin.guests.export', 'Export')}
>
<Download className="w-4 h-4" />
</button>
<div className="absolute right-0 top-full mt-1 hidden group-hover:block bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded shadow-lg z-10 min-w-[100px]">
{(['csv', 'txt', 'json'] as const).map((fmt) => (
<button
key={fmt}
onClick={() => handleExport(guest, fmt)}
className="block w-full text-left px-3 py-2 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-700"
>
{fmt.toUpperCase()}
</button>
))}
</div>
</div>
<button
type="button"
onClick={() => handleDelete(guest)}
className="p-1 text-neutral-500 hover:text-red-600"
title={t('admin.guests.forgetGuest', 'Remove guest')}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
{selectedGuest && (
<AdminGuestDetail
eventId={eventId}
guest={selectedGuest}
onClose={() => setSelectedGuest(null)}
/>
)}
{inviteDialogOpen && (
<GuestInviteDialog
eventId={eventId}
eventName={eventName}
onClose={() => {
setInviteDialogOpen(false);
refetch();
}}
/>
)}
</div>
);
};
+26 -3
View File
@@ -13,6 +13,7 @@ import { PasswordChangeModal } from './PasswordChangeModal';
import { LanguageSelector } from '../common';
import { notificationsService } from '../../services/notifications.service';
import { toast } from 'react-toastify';
import { buildResourceUrl, getApiBaseUrl } from '../../utils/url';
interface AdminHeaderProps {
onMenuClick: () => void;
@@ -30,6 +31,24 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
const [showPasswordModal, setShowPasswordModal] = useState(false);
const queryClient = useQueryClient();
// Fetch branding settings
const { data: brandingSettings } = useQuery({
queryKey: ['admin-settings', 'branding'],
queryFn: async () => {
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
if (response.ok) return response.json();
return null;
},
staleTime: 5 * 60 * 1000,
});
const companyName = brandingSettings?.branding_company_name?.trim() || 'PicPeak';
const logoUrl = brandingSettings?.branding_logo_url?.trim();
const logoDisplayMode = brandingSettings?.branding_logo_display_mode || 'logo_and_text';
const resolvedLogoUrl = logoUrl
? (logoUrl.startsWith('http') ? logoUrl : buildResourceUrl(logoUrl))
: '/picpeak-kamera-transparent.png';
const userMenuRef = useRef<HTMLDivElement>(null);
const notificationRef = useRef<HTMLDivElement>(null);
@@ -82,10 +101,14 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
<Menu className="w-6 h-6" />
</button>
{/* PicPeak logo - sticky to the left on all sizes */}
{/* Logo - sticky to the left on all sizes */}
<div className="flex items-center gap-2">
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-8 w-auto object-contain" />
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
{(logoDisplayMode === 'logo_only' || logoDisplayMode === 'logo_and_text') && (
<img src={resolvedLogoUrl} alt={companyName} className="h-8 w-auto object-contain" />
)}
{(logoDisplayMode === 'text_only' || logoDisplayMode === 'logo_and_text') && (
<span className="text-xl sm:text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>{companyName}</span>
)}
</div>
{/* Date display - hidden on smaller screens */}
@@ -1,5 +1,5 @@
import React, { useState } from 'react';
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video, FolderOpen } from 'lucide-react';
import { Check, Download, Trash2, Eye, EyeOff, Package, MessageSquare, Star, Video, FolderOpen } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
@@ -201,6 +201,34 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
>
{t('photos.moveToCategory', 'Move to Category')}
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'hidden' });
toast.success(t('admin.photos.hiddenSuccess', 'Photos hidden'));
onPhotosDeleted();
} catch { toast.error(t('common.error')); }
}}
leftIcon={<EyeOff className="w-4 h-4" />}
>
{t('admin.photos.hideSelected', 'Hide')}
</Button>
<Button
variant="outline"
size="sm"
onClick={async () => {
try {
await photosService.bulkUpdatePhotos(eventId, Array.from(selectedPhotos), { visibility: 'visible' });
toast.success(t('admin.photos.visibleSuccess', 'Photos visible'));
onPhotosDeleted();
} catch { toast.error(t('common.error')); }
}}
leftIcon={<Eye className="w-4 h-4" />}
>
{t('admin.photos.showSelected', 'Show')}
</Button>
<button
onClick={handleDeleteSelected}
disabled={isDeleting}
@@ -260,6 +288,16 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
</div>
</button>
{/* Visibility badge (#172) */}
{(photo as any).visibility === 'hidden' && (
<div className="absolute top-2 left-2 z-20">
<span className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-red-500/90 text-white text-[10px] font-medium">
<EyeOff className="w-3 h-3" />
{t('admin.photos.hidden', 'Hidden')}
</span>
</div>
)}
{/* Thumbnail */}
<div className="aspect-square">
{photo.thumbnail_url ? (
@@ -76,6 +76,7 @@ export const EmailPreviewModal: React.FC<EmailPreviewModalProps> = ({
srcDoc={htmlContent}
className="w-full h-[600px] border-0"
title="Email Preview"
sandbox="allow-same-origin"
/>
</div>
) : (
@@ -0,0 +1,398 @@
import React, { useState, useCallback } from 'react';
import { useEditor, EditorContent, type Editor } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import HardBreak from '@tiptap/extension-hard-break';
import TextAlign from '@tiptap/extension-text-align';
import {
Bold,
Italic,
List,
ListOrdered,
Link as LinkIcon,
Heading2,
Heading3,
Quote,
Minus,
Undo,
Redo,
RemoveFormatting,
AlignLeft,
AlignCenter,
AlignRight,
Code2,
Variable,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface EmailTemplateEditorProps {
content: string;
onChange: (content: string) => void;
variables?: string[];
}
export const EmailTemplateEditor: React.FC<EmailTemplateEditorProps> = ({
content,
onChange,
variables = [],
}) => {
const { t } = useTranslation();
const [isSourceMode, setIsSourceMode] = useState(false);
const [sourceContent, setSourceContent] = useState(content);
const [linkUrl, setLinkUrl] = useState('');
const [showLinkDialog, setShowLinkDialog] = useState(false);
const [showVariables, setShowVariables] = useState(false);
const editor = useEditor({
extensions: [
StarterKit.configure({
hardBreak: false,
}),
HardBreak.configure({
keepMarks: true,
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer',
},
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
alignments: ['left', 'center', 'right'],
defaultAlignment: 'left',
}),
],
content,
onUpdate: ({ editor }) => {
const html = editor.getHTML();
onChange(html);
setSourceContent(html);
},
});
// Sync editor when content prop changes externally
React.useEffect(() => {
if (editor && !isSourceMode && content !== editor.getHTML()) {
editor.commands.setContent(content);
setSourceContent(content);
}
}, [content, editor, isSourceMode]);
const handleSourceChange = useCallback((value: string) => {
setSourceContent(value);
onChange(value);
}, [onChange]);
const switchToVisual = useCallback(() => {
if (editor) {
editor.commands.setContent(sourceContent);
}
setIsSourceMode(false);
}, [editor, sourceContent]);
const switchToSource = useCallback(() => {
if (editor) {
setSourceContent(editor.getHTML());
}
setIsSourceMode(true);
}, [editor]);
const insertVariable = useCallback((variable: string) => {
const tag = `{{${variable}}}`;
if (isSourceMode) {
// Insert at cursor in textarea
const textarea = document.querySelector('[data-email-source]') as HTMLTextAreaElement;
if (textarea) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const newContent = sourceContent.substring(0, start) + tag + sourceContent.substring(end);
setSourceContent(newContent);
onChange(newContent);
// Restore cursor position after React re-render
requestAnimationFrame(() => {
textarea.selectionStart = textarea.selectionEnd = start + tag.length;
textarea.focus();
});
}
} else if (editor) {
editor.chain().focus().insertContent(tag).run();
}
setShowVariables(false);
}, [editor, isSourceMode, sourceContent, onChange]);
const addLink = useCallback(() => {
if (linkUrl && editor) {
editor.chain().focus().setLink({ href: linkUrl }).run();
setLinkUrl('');
setShowLinkDialog(false);
}
}, [editor, linkUrl]);
if (!editor) {
return null;
}
const MenuButton: React.FC<{
onClick: () => void;
active?: boolean;
children: React.ReactNode;
title: string;
disabled?: boolean;
}> = ({ onClick, active, children, title, disabled }) => (
<button
onClick={onClick}
disabled={disabled}
className={`p-1.5 rounded hover:bg-neutral-100 dark:hover:bg-neutral-600 transition-colors ${
active
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'
: 'text-neutral-700 dark:text-neutral-300'
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
title={title}
type="button"
>
{children}
</button>
);
return (
<div className="border border-neutral-300 dark:border-neutral-600 rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800">
<div className="flex items-center justify-between p-2">
{/* Formatting buttons */}
<div className="flex items-center gap-0.5 flex-wrap">
{!isSourceMode && (
<>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<Heading2 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor.isActive('heading', { level: 3 })}
title="Heading 3"
>
<Heading3 className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
title={`${t('email.editor.bold')} (Ctrl+B)`}
>
<Bold className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
title={`${t('email.editor.italic')} (Ctrl+I)`}
>
<Italic className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
title={t('email.editor.bulletList')}
>
<List className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
title={t('email.editor.numberedList')}
>
<ListOrdered className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')}
title={t('email.editor.blockquote')}
>
<Quote className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => setShowLinkDialog(true)}
active={editor.isActive('link')}
title={t('email.editor.link')}
>
<LinkIcon className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title={t('email.editor.horizontalRule')}
>
<Minus className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
active={editor.isActive({ textAlign: 'left' })}
title={t('email.editor.alignLeft')}
>
<AlignLeft className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
active={editor.isActive({ textAlign: 'center' })}
title={t('email.editor.alignCenter')}
>
<AlignCenter className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
active={editor.isActive({ textAlign: 'right' })}
title={t('email.editor.alignRight')}
>
<AlignRight className="w-4 h-4" />
</MenuButton>
<div className="w-px h-5 bg-neutral-300 dark:bg-neutral-600 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title={t('email.editor.clearFormatting')}
>
<RemoveFormatting className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title={`${t('email.editor.undo')} (Ctrl+Z)`}
>
<Undo className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title={`${t('email.editor.redo')} (Ctrl+Y)`}
>
<Redo className="w-4 h-4" />
</MenuButton>
</>
)}
</div>
{/* Right side: Variables + Source toggle */}
<div className="flex items-center gap-2">
{variables.length > 0 && (
<div className="relative">
<button
onClick={() => setShowVariables(!showVariables)}
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-colors ${
showVariables
? 'bg-primary-100 dark:bg-primary-900/40 text-primary-700 dark:text-primary-300'
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
}`}
type="button"
>
<Variable className="w-3.5 h-3.5" />
{t('email.editor.insertVariable')}
</button>
{showVariables && (
<div className="absolute right-0 top-full mt-1 z-10 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-600 rounded-lg shadow-lg py-1 min-w-[200px] max-h-[240px] overflow-auto">
{variables.map(variable => (
<button
key={variable}
onClick={() => insertVariable(variable)}
className="w-full text-left px-3 py-1.5 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
type="button"
>
<code className="text-primary-600 dark:text-primary-400">{`{{${variable}}}`}</code>
</button>
))}
</div>
)}
</div>
)}
<button
onClick={isSourceMode ? switchToVisual : switchToSource}
className={`flex items-center gap-1 px-2 py-1 text-xs font-medium rounded transition-colors ${
isSourceMode
? 'bg-amber-100 dark:bg-amber-900/40 text-amber-700 dark:text-amber-300'
: 'bg-neutral-100 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 hover:bg-neutral-200 dark:hover:bg-neutral-600'
}`}
type="button"
>
<Code2 className="w-3.5 h-3.5" />
{isSourceMode ? t('email.editor.visualMode') : t('email.editor.sourceMode')}
</button>
</div>
</div>
</div>
{/* Link Dialog */}
{showLinkDialog && (
<div className="p-3 bg-primary-50 dark:bg-primary-900/20 border-b border-primary-200 dark:border-primary-800 flex items-center gap-2">
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && addLink()}
placeholder={t('email.editor.enterUrl')}
className="flex-1 px-3 py-1 text-sm border border-primary-300 dark:border-primary-700 bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<button
onClick={addLink}
className="px-3 py-1 text-sm bg-primary-600 text-white rounded-md hover:bg-primary-700"
type="button"
>
{t('email.editor.addLink')}
</button>
<button
onClick={() => { setShowLinkDialog(false); setLinkUrl(''); }}
className="px-3 py-1 text-sm bg-neutral-200 dark:bg-neutral-700 text-neutral-700 dark:text-neutral-300 rounded-md hover:bg-neutral-300 dark:hover:bg-neutral-600"
type="button"
>
{t('email.editor.cancel')}
</button>
</div>
)}
{/* Editor / Source Content Area */}
{isSourceMode ? (
<textarea
data-email-source=""
value={sourceContent}
onChange={(e) => handleSourceChange(e.target.value)}
rows={15}
className="w-full px-3 py-2 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 font-mono text-sm focus:outline-none resize-y"
spellCheck={false}
/>
) : (
<EditorContent
editor={editor}
className="min-h-[300px] p-4 prose prose-neutral dark:prose-invert max-w-none bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100 focus:outline-none [&_.ProseMirror]:min-h-[300px] [&_.ProseMirror]:outline-none [&_.ProseMirror]:text-neutral-900 [&_.ProseMirror]:dark:text-neutral-100 [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-400 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0"
/>
)}
</div>
);
};
EmailTemplateEditor.displayName = 'EmailTemplateEditor';
@@ -1,5 +1,5 @@
import React from 'react';
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye } from 'lucide-react';
import { MessageSquare, Star, Heart, Bookmark, Shield, Eye, User, Users } from 'lucide-react';
import { Card } from '../common';
import { useTranslation } from 'react-i18next';
@@ -21,6 +21,7 @@ interface FeedbackSettings {
enable_rate_limiting: boolean;
rate_limit_window_minutes?: number;
rate_limit_max_requests?: number;
identity_mode?: 'simple' | 'guest';
}
export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
@@ -70,6 +71,74 @@ export const FeedbackSettings: React.FC<FeedbackSettingsProps> = ({
{settings.feedback_enabled && (
<>
{/* Identity Mode */}
<div className="space-y-3">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('feedback.settings.identityMode', 'Identity Mode')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<label
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
(settings.identity_mode || 'simple') === 'simple'
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
<input
type="radio"
name="identity_mode"
value="simple"
checked={(settings.identity_mode || 'simple') === 'simple'}
onChange={() => onChange({ ...settings, identity_mode: 'simple' })}
className="mt-0.5 w-4 h-4 text-primary-600 focus:ring-primary-500"
/>
<User className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.identityModeSimple', 'Simple feedback')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t(
'feedback.settings.identityModeSimpleDesc',
'Anonymous, device-based. All visitors on the same device share state.'
)}
</div>
</div>
</label>
<label
className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition ${
settings.identity_mode === 'guest'
? 'border-primary-500 bg-primary-50 dark:bg-primary-900/20'
: 'border-neutral-200 dark:border-neutral-700 hover:bg-neutral-50 dark:hover:bg-neutral-800'
}`}
>
<input
type="radio"
name="identity_mode"
value="guest"
checked={settings.identity_mode === 'guest'}
onChange={() => onChange({ ...settings, identity_mode: 'guest' })}
className="mt-0.5 w-4 h-4 text-primary-600 focus:ring-primary-500"
/>
<Users className="w-5 h-5 mt-0.5 text-neutral-600 dark:text-neutral-400" />
<div className="flex-1">
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{t('feedback.settings.identityModeGuest', 'Per-guest selections')}
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400">
{t(
'feedback.settings.identityModeGuestDesc',
'Each visitor enters their name. Enables per-guest tracking and admin insights.'
)}
</div>
</div>
</label>
</div>
</div>
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-4" />
{/* Feedback Types */}
<div className="space-y-4">
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
@@ -0,0 +1,194 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { X, Copy, Check, Trash2 } from 'lucide-react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, Input, Loading } from '../common';
import { guestsService, GuestInvite } from '../../services/guests.service';
import { toast } from 'react-toastify';
interface GuestInviteDialogProps {
eventId: number;
eventName?: string;
onClose: () => void;
}
/**
* Admin dialog to create pre-minted invite tokens and list existing ones.
* Each invite generates a unique URL that the admin can send to a specific
* guest. Opening the URL auto-registers that guest (single use).
*/
export const GuestInviteDialog: React.FC<GuestInviteDialogProps> = ({ eventId, onClose }) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [copiedId, setCopiedId] = useState<number | null>(null);
const { data, isLoading } = useQuery({
queryKey: ['admin-guest-invites', eventId],
queryFn: () => guestsService.listInvites(eventId),
});
const createMutation = useMutation({
mutationFn: () => guestsService.createInvite(eventId, { name, email: email || undefined }),
onSuccess: () => {
setName('');
setEmail('');
toast.success(t('admin.guests.inviteCreated', 'Invite created'));
queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] });
queryClient.invalidateQueries({ queryKey: ['admin-guests', eventId] });
},
onError: () => toast.error(t('admin.guests.inviteCreateError', 'Failed to create invite')),
});
const revokeMutation = useMutation({
mutationFn: (inviteId: number) => guestsService.revokeInvite(eventId, inviteId),
onSuccess: () => {
toast.success(t('admin.guests.inviteRevoked', 'Invite revoked'));
queryClient.invalidateQueries({ queryKey: ['admin-guest-invites', eventId] });
},
onError: () => toast.error(t('admin.guests.inviteRevokeError', 'Failed to revoke invite')),
});
const copy = (invite: GuestInvite) => {
navigator.clipboard.writeText(invite.url).then(() => {
setCopiedId(invite.id);
setTimeout(() => setCopiedId(null), 1500);
});
};
const invites = data?.invites || [];
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-16">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white dark:bg-neutral-900 rounded-lg shadow-xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700 flex items-center justify-between">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">
{t('admin.guests.invitesTitle', 'Guest invites')}
</h2>
<button
type="button"
onClick={onClose}
className="p-1 text-neutral-500 hover:text-neutral-900 dark:hover:text-neutral-100"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="overflow-y-auto p-4 space-y-4">
{/* Create form */}
<div className="p-4 bg-neutral-50 dark:bg-neutral-800 rounded">
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-3">
{t('admin.guests.createInvite', 'Create invite')}
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mb-3">
<Input
label={t('admin.guests.inviteName', 'Guest name')}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g. Alice"
required
/>
<Input
type="email"
label={t('admin.guests.inviteEmail', 'Email (optional)')}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="alice@example.com"
/>
</div>
<Button
variant="primary"
size="sm"
onClick={() => createMutation.mutate()}
disabled={!name.trim() || createMutation.isPending}
>
{createMutation.isPending
? t('common.submitting', 'Submitting...')
: t('admin.guests.generateInvite', 'Generate invite link')}
</Button>
</div>
{/* Existing invites */}
<div>
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 mb-2">
{t('admin.guests.existingInvites', 'Existing invites')}
</h3>
{isLoading ? (
<Loading size="sm" />
) : invites.length === 0 ? (
<div className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-4">
{t('admin.guests.noInvites', 'No invites yet')}
</div>
) : (
<div className="space-y-2">
{invites.map((invite) => (
<div
key={invite.id}
className="p-3 bg-white dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="font-medium text-sm text-neutral-900 dark:text-neutral-100">
{invite.guest.name}
{invite.guest.email && (
<span className="text-neutral-500 dark:text-neutral-400 font-normal ml-2">
· {invite.guest.email}
</span>
)}
</div>
<div className="text-xs mt-1">
<span
className={`inline-block px-2 py-0.5 rounded-full font-medium ${
invite.status === 'redeemed'
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: invite.status === 'revoked'
? 'bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300'
: 'bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400'
}`}
>
{t(`admin.guests.inviteStatus.${invite.status}`, invite.status)}
</span>
</div>
<div className="text-xs text-neutral-500 dark:text-neutral-400 truncate mt-1 font-mono">
{invite.url}
</div>
</div>
<div className="flex gap-1">
{invite.status === 'pending' && (
<>
<button
type="button"
onClick={() => copy(invite)}
className="p-1.5 text-neutral-500 hover:text-primary-600"
title={t('admin.guests.copyLink', 'Copy link')}
>
{copiedId === invite.id ? (
<Check className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
<button
type="button"
onClick={() => revokeMutation.mutate(invite.id)}
className="p-1.5 text-neutral-500 hover:text-red-600"
title={t('admin.guests.revokeInvite', 'Revoke')}
>
<Trash2 className="w-4 h-4" />
</button>
</>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
);
};
@@ -0,0 +1,69 @@
import React from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Users } from 'lucide-react';
import { Card, Loading } from '../common';
import { guestsService } from '../../services/guests.service';
import { AuthenticatedImage } from '../common/AuthenticatedImage';
import { buildResourceUrl } from '../../utils/url';
interface GuestSelectionsAggregateProps {
eventId: number;
}
/**
* Shows photos sorted by the number of distinct guests who liked or
* favorited them. Photos with zero picks are filtered server-side.
*/
export const GuestSelectionsAggregate: React.FC<GuestSelectionsAggregateProps> = ({ eventId }) => {
const { t } = useTranslation();
const { data, isLoading } = useQuery({
queryKey: ['admin-guests-aggregate', eventId],
queryFn: () => guestsService.getAggregatePicks(eventId),
});
if (isLoading) {
return <Loading size="lg" text={t('admin.guests.loading', 'Loading...')} />;
}
const photos = data?.photos || [];
if (photos.length === 0) {
return (
<Card>
<div className="p-8 text-center text-neutral-500 dark:text-neutral-400">
{t('admin.guests.aggregate.empty', 'No guest picks yet.')}
</div>
</Card>
);
}
return (
<div className="space-y-3">
<p className="text-sm text-neutral-600 dark:text-neutral-400">
{t(
'admin.guests.aggregate.description',
'Photos sorted by how many distinct guests liked or favorited them.'
)}
</p>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
{photos.map((p) => (
<div key={p.id} className="relative group">
<AuthenticatedImage
src={buildResourceUrl(p.thumbnail_url)}
alt={p.filename}
className="w-full aspect-square object-cover rounded"
/>
<div className="absolute top-2 right-2 bg-primary-600 text-white text-xs font-semibold px-2 py-1 rounded-full flex items-center gap-1 shadow">
<Users className="w-3 h-3" />
{p.picker_count}
</div>
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent opacity-0 group-hover:opacity-100 transition-opacity text-white text-xs p-2 rounded-b">
{p.original_filename || p.filename}
</div>
</div>
))}
</div>
</div>
);
};
@@ -27,14 +27,12 @@ export const MandatoryPasswordChangeModal: React.FC = () => {
mutationFn: adminService.changePassword,
onSuccess: () => {
toast.success(t('mandatoryPasswordChange.success'));
updatePasswordChanged();
// Reset form
setFormData({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
setErrors({});
// Force a full page reload so the browser picks up the new JWT cookie
// set by the backend. A React state update alone causes a race condition
// where the auth context checks the session before the cookie is stored.
setTimeout(() => {
window.location.href = '/admin/dashboard';
}, 2000);
},
onError: (error: any) => {
if (error.response?.data?.error) {
@@ -30,14 +30,12 @@ export const PasswordChangeModal: React.FC<PasswordChangeModalProps> = ({ isOpen
mutationFn: adminService.changePassword,
onSuccess: () => {
toast.success(t('passwordChange.success'));
onClose();
// Reset form
setFormData({
currentPassword: '',
newPassword: '',
confirmPassword: ''
});
setErrors({});
// Full page reload so the browser picks up the new JWT cookie.
// Same fix as MandatoryPasswordChangeModal — without this, the old
// token gets rejected and causes a redirect loop.
setTimeout(() => {
window.location.href = '/admin/dashboard';
}, 2000);
},
onError: (error: any) => {
if (error.response?.data?.error) {
@@ -1,10 +1,10 @@
import React, { useState, useEffect } from 'react';
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode, ImageIcon, Minimize2, EyeOff, Menu, SlidersHorizontal, Columns, Film } from 'lucide-react';
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode, ImageIcon, Minimize2, EyeOff, Menu, SlidersHorizontal, Columns, Film, AlertTriangle } from 'lucide-react';
import { Button, Card, Input } from '../common';
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType, HeaderStyleType, HeroDividerStyle } from '../../types/theme.types';
import type { EnabledTemplate } from '../../services/cssTemplates.service';
// import { settingsService } from '../../services/settings.service';
// import { toast } from 'react-toastify';
import { settingsService } from '../../services/settings.service';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
interface ThemeCustomizerEnhancedProps {
@@ -90,7 +90,21 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
const [selectedPreset, setSelectedPreset] = useState(presetName);
const [customCss, setCustomCss] = useState(value.customCss || '');
const [showCssInstructions, setShowCssInstructions] = useState(false);
// const logoInputRef = useRef<HTMLInputElement>(null);
const BETA_LAYOUTS: GalleryLayoutType[] = ['gallery-premium', 'gallery-story'];
const MIN_RECOMMENDED_THUMBNAIL_SIZE = 500;
// Fetch thumbnail settings to warn about low resolution with beta themes
const { data: allSettings } = useQuery({
queryKey: ['admin-settings'],
queryFn: () => settingsService.getAllSettings(),
staleTime: 60000,
});
const thumbnailWidth = parseInt(allSettings?.thumbnail_width) || 300;
const thumbnailHeight = parseInt(allSettings?.thumbnail_height) || 300;
const isBetaLayout = BETA_LAYOUTS.includes(localTheme.galleryLayout as GalleryLayoutType);
const isThumbnailTooSmall = Math.max(thumbnailWidth, thumbnailHeight) < MIN_RECOMMENDED_THUMBNAIL_SIZE;
useEffect(() => {
setLocalTheme(value);
@@ -230,6 +244,33 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</button>
))}
</div>
{/* Warning: Beta preset with low thumbnail resolution */}
{isBetaLayout && isThumbnailTooSmall && !showGalleryLayouts && (
<div className="mt-4 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
{t('branding.betaThumbnailWarningTitle')}
</p>
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
{t('branding.betaThumbnailWarningText', { width: thumbnailWidth, height: thumbnailHeight, recommended: MIN_RECOMMENDED_THUMBNAIL_SIZE })}
</p>
<a
href="/admin/settings"
className="inline-flex items-center gap-1 mt-2 text-sm font-medium text-amber-800 dark:text-amber-300 hover:underline"
onClick={(e) => {
e.preventDefault();
window.location.href = '/admin/settings';
}}
>
{t('branding.betaThumbnailWarningLink')}
</a>
</div>
</div>
</div>
)}
</Card>
{/* Gallery Layout */}
@@ -271,6 +312,33 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
))}
</div>
{/* Warning: Beta theme with low thumbnail resolution */}
{isBetaLayout && isThumbnailTooSmall && (
<div className="mt-4 p-4 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-amber-600 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
{t('branding.betaThumbnailWarningTitle')}
</p>
<p className="text-sm text-amber-700 dark:text-amber-400 mt-1">
{t('branding.betaThumbnailWarningText', { width: thumbnailWidth, height: thumbnailHeight, recommended: MIN_RECOMMENDED_THUMBNAIL_SIZE })}
</p>
<a
href="/admin/settings"
className="inline-flex items-center gap-1 mt-2 text-sm font-medium text-amber-800 dark:text-amber-300 hover:underline"
onClick={(e) => {
e.preventDefault();
window.location.href = '/admin/settings';
}}
>
{t('branding.betaThumbnailWarningLink')}
</a>
</div>
</div>
</div>
)}
{/* Layout-specific settings */}
{localTheme.galleryLayout && (
<div className="mt-6 space-y-4 pt-6 border-t border-neutral-200 dark:border-neutral-700">
@@ -312,52 +380,73 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
{/* Grid specific */}
{localTheme.galleryLayout === 'grid' && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.columns')}
</label>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.mobile')}</label>
<Input
type="number"
min="1"
max="4"
value={localTheme.gallerySettings?.gridColumns?.mobile || 2}
onChange={(e) => updateGallerySettings('gridColumns', {
...localTheme.gallerySettings?.gridColumns,
mobile: parseInt(e.target.value)
})}
/>
</div>
<div>
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.tablet')}</label>
<Input
type="number"
min="2"
max="6"
value={localTheme.gallerySettings?.gridColumns?.tablet || 3}
onChange={(e) => updateGallerySettings('gridColumns', {
...localTheme.gallerySettings?.gridColumns,
tablet: parseInt(e.target.value)
})}
/>
</div>
<div>
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.desktop')}</label>
<Input
type="number"
min="3"
max="8"
value={localTheme.gallerySettings?.gridColumns?.desktop || 4}
onChange={(e) => updateGallerySettings('gridColumns', {
...localTheme.gallerySettings?.gridColumns,
desktop: parseInt(e.target.value)
})}
/>
<>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.columns')}
</label>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.mobile')}</label>
<Input
type="number"
min="1"
max="4"
value={localTheme.gallerySettings?.gridColumns?.mobile || 2}
onChange={(e) => updateGallerySettings('gridColumns', {
...localTheme.gallerySettings?.gridColumns,
mobile: parseInt(e.target.value)
})}
/>
</div>
<div>
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.tablet')}</label>
<Input
type="number"
min="2"
max="6"
value={localTheme.gallerySettings?.gridColumns?.tablet || 3}
onChange={(e) => updateGallerySettings('gridColumns', {
...localTheme.gallerySettings?.gridColumns,
tablet: parseInt(e.target.value)
})}
/>
</div>
<div>
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.desktop')}</label>
<Input
type="number"
min="3"
max="8"
value={localTheme.gallerySettings?.gridColumns?.desktop || 4}
onChange={(e) => updateGallerySettings('gridColumns', {
...localTheme.gallerySettings?.gridColumns,
desktop: parseInt(e.target.value)
})}
/>
</div>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.thumbnailScale', 'Thumbnail Scale')}
</label>
<select
value={localTheme.gallerySettings?.thumbnailScale || 'md'}
onChange={(e) => updateGallerySettings('thumbnailScale', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="xs">{t('branding.thumbnailScaleOptions.xs', 'XS — Most photos')}</option>
<option value="sm">{t('branding.thumbnailScaleOptions.sm', 'SM — More photos')}</option>
<option value="md">{t('branding.thumbnailScaleOptions.md', 'MD — Default')}</option>
<option value="lg">{t('branding.thumbnailScaleOptions.lg', 'LG — Larger photos')}</option>
<option value="xl">{t('branding.thumbnailScaleOptions.xl', 'XL — Largest photos')}</option>
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')}
</p>
</div>
</>
)}
{/* Carousel specific */}
@@ -437,6 +526,29 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
</p>
</div>
{/* Thumbnail scale - only for columns mode */}
{(!localTheme.gallerySettings?.masonryMode || localTheme.gallerySettings?.masonryMode === 'columns') && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.thumbnailScale', 'Thumbnail Scale')}
</label>
<select
value={localTheme.gallerySettings?.thumbnailScale || 'md'}
onChange={(e) => updateGallerySettings('thumbnailScale', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="xs">{t('branding.thumbnailScaleOptions.xs', 'XS — Most photos')}</option>
<option value="sm">{t('branding.thumbnailScaleOptions.sm', 'SM — More photos')}</option>
<option value="md">{t('branding.thumbnailScaleOptions.md', 'MD — Default')}</option>
<option value="lg">{t('branding.thumbnailScaleOptions.lg', 'LG — Larger photos')}</option>
<option value="xl">{t('branding.thumbnailScaleOptions.xl', 'XL — Largest photos')}</option>
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')}
</p>
</div>
)}
{/* Row-specific settings - show for all row-based modes */}
{['rows', 'flickr', 'justified'].includes(localTheme.gallerySettings?.masonryMode || '') && (
<>
@@ -476,6 +588,29 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
)}
</>
)}
{/* Mosaic specific */}
{localTheme.galleryLayout === 'mosaic' && (
<div>
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
{t('branding.thumbnailScale', 'Thumbnail Scale')}
</label>
<select
value={localTheme.gallerySettings?.thumbnailScale || 'md'}
onChange={(e) => updateGallerySettings('thumbnailScale', e.target.value)}
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
>
<option value="xs">{t('branding.thumbnailScaleOptions.xs', 'XS — Most photos')}</option>
<option value="sm">{t('branding.thumbnailScaleOptions.sm', 'SM — More photos')}</option>
<option value="md">{t('branding.thumbnailScaleOptions.md', 'MD — Default')}</option>
<option value="lg">{t('branding.thumbnailScaleOptions.lg', 'LG — Larger photos')}</option>
<option value="xl">{t('branding.thumbnailScaleOptions.xl', 'XL — Largest photos')}</option>
</select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('branding.thumbnailScaleHint', 'Adjusts column count relative to the base grid columns')}
</p>
</div>
)}
</div>
)}
</Card>
+4
View File
@@ -36,3 +36,7 @@ export { EventRenameDialog } from './EventRenameDialog';
export { PhotoFilterPanel } from './PhotoFilterPanel';
export { PhotoExportMenu } from './PhotoExportMenu';
export { CssTemplateEditor } from './CssTemplateEditor';
export { AdminGuestsList } from './AdminGuestsList';
export { AdminGuestDetail } from './AdminGuestDetail';
export { GuestSelectionsAggregate } from './GuestSelectionsAggregate';
export { GuestInviteDialog } from './GuestInviteDialog';
@@ -40,7 +40,7 @@ export const DynamicFavicon: React.FC = () => {
}
}, [settings?.branding_favicon_url]);
// Update document title when company name or tagline changes
// Update document title and OG meta tags when company name or tagline changes
useEffect(() => {
const companyName = settings?.branding_company_name?.trim();
const tagline = settings?.branding_company_tagline?.trim();
@@ -52,6 +52,33 @@ export const DynamicFavicon: React.FC = () => {
} else {
document.title = DEFAULT_TITLE;
}
// Update OG meta tags
const title = companyName || 'PicPeak';
const description = tagline || 'Photo Sharing Platform';
const updateMeta = (property: string, content: string) => {
let meta = document.querySelector(`meta[property="${property}"]`) as HTMLMetaElement | null;
if (!meta) {
meta = document.createElement('meta');
meta.setAttribute('property', property);
document.head.appendChild(meta);
}
meta.content = content;
};
updateMeta('og:title', document.title);
updateMeta('og:site_name', title);
updateMeta('og:description', description);
// Also update standard meta description
let metaDesc = document.querySelector('meta[name="description"]') as HTMLMetaElement | null;
if (!metaDesc) {
metaDesc = document.createElement('meta');
metaDesc.name = 'description';
document.head.appendChild(metaDesc);
}
metaDesc.content = description;
}, [settings?.branding_company_name, settings?.branding_company_tagline]);
return null;
@@ -21,9 +21,37 @@ const DEFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) =>
</svg>
);
const RUFlag: 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="#FFF" d="M0 0h640v160H0z"/>
<path fill="#0039A6" d="M0 160h640v160H0z"/>
<path fill="#D52B1E" d="M0 320h640v160H0z"/>
</svg>
);
const PTBRFlag: 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="#009B3A" d="M0 0h640v480H0z"/>
<path fill="#FEDF00" d="M320 39.4 590.4 240 320 440.6 49.6 240z"/>
<circle fill="#002776" cx="320" cy="240" r="95"/>
<path fill="#FFF" d="M226.3 262.8c0-27 12.8-51 32.7-66.3a95.3 95.3 0 0 0-3.5 120.6c-17.8-14.8-29.2-37-29.2-54.3z" opacity=".5"/>
</svg>
);
const NLFlag: 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="#AE1C28" d="M0 0h640v160H0z"/>
<path fill="#FFF" d="M0 160h640v160H0z"/>
<path fill="#21468B" d="M0 320h640v160H0z"/>
</svg>
);
const languages = [
{ code: 'en', name: 'English', Flag: GBFlag },
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
{ code: 'ru', name: 'Русский', Flag: RUFlag },
{ code: 'pt', name: 'Português', Flag: PTBRFlag },
{ code: 'nl', name: 'Nederlands', Flag: NLFlag },
];
export const LanguageSelector: React.FC = () => {
@@ -7,6 +7,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
import { Button } from '../common';
import { DynamicFavicon } from '../common/DynamicFavicon';
import { useTheme } from '../../contexts/ThemeContext';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
import { buildResourceUrl } from '../../utils/url';
import type { HeaderStyleType } from '../../types/theme.types';
@@ -59,6 +60,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
const { t } = useTranslation();
const { format } = useLocalizedDate();
const { theme } = useTheme();
const guestIdentity = useGuestIdentityOptional();
// Determine header style - use prop first (from event data), then theme, then fall back to 'standard'
const headerStyle: HeaderStyleType = headerStyleProp || theme.headerStyle || 'standard';
@@ -589,20 +591,36 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
</p>
)}
{/* Legal Links */}
<div className="mt-4 flex items-center justify-center gap-4">
<Link
to="/impressum"
<div className="mt-4 flex items-center justify-center gap-4 flex-wrap">
<Link
to="/impressum"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-muted-theme">|</span>
<Link
to="/datenschutz"
<Link
to="/datenschutz"
className="text-xs text-muted-theme hover:text-theme transition-colors"
>
{t('legal.datenschutz')}
</Link>
{guestIdentity?.identity && (
<>
<span className="text-xs text-muted-theme">|</span>
<button
type="button"
className="text-xs text-muted-theme hover:text-theme transition-colors"
onClick={async () => {
if (window.confirm(t('gallery.footer.forgetMeConfirm', 'Your name and selections will be removed from this gallery.'))) {
await guestIdentity.forget();
}
}}
>
{t('gallery.footer.forgetMe', 'Forget me ({{name}})', { name: guestIdentity.identity.name })}
</button>
</>
)}
</div>
</div>
</footer>
@@ -356,7 +356,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
<button
key={option.value}
onClick={() => {
onSortChange(option.value as 'date' | 'name' | 'size' | 'rating');
onSortChange(option.value as 'date' | 'name' | 'size' | 'rating' | 'capture_date');
if (isMobile) onClose();
}}
className={`
+136 -18
View File
@@ -13,16 +13,20 @@ import { GalleryLayout } from './GalleryLayout';
import { GallerySidebar } from './GallerySidebar';
import { PhotoFilterBar } from './PhotoFilterBar';
import { UserPhotoUpload } from './UserPhotoUpload';
import { GuestNamePromptModal } from './GuestNamePromptModal';
import { GuestRecoveryModal } from './GuestRecoveryModal';
import { GuestIdentityProvider } from '../../contexts/GuestIdentityContext';
import type { FilterType } from './GalleryFilter';
import { analyticsService } from '../../services/analytics.service';
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { api } from '../../config/api';
import { Upload, Menu } from 'lucide-react';
import { Upload, Menu, Eye, EyeOff, Shield } from 'lucide-react';
import { galleryService } from '../../services/gallery.service';
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
import type { Photo } from '../../types';
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
import { useQueryClient } from '@tanstack/react-query';
interface GalleryViewProps {
slug: string;
@@ -41,13 +45,35 @@ interface GalleryViewProps {
};
}
// Convert default_photo_sort DB value to internal sortBy state
const parseDefaultPhotoSort = (defaultSort?: string): { sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date'; sortDesc: boolean } => {
switch (defaultSort) {
case 'upload_date_asc':
return { sortBy: 'date', sortDesc: false };
case 'capture_date_desc':
return { sortBy: 'capture_date', sortDesc: true };
case 'capture_date_asc':
return { sortBy: 'capture_date', sortDesc: false };
case 'filename_asc':
return { sortBy: 'name', sortDesc: false };
case 'filename_desc':
return { sortBy: 'name', sortDesc: true };
case 'upload_date_desc':
default:
return { sortBy: 'date', sortDesc: true };
}
};
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { t } = useTranslation();
const { logout } = useGalleryAuth();
const { logout, isClient } = useGalleryAuth();
const { setTheme, theme } = useTheme();
const queryClient = useQueryClient();
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
const [sortDesc, setSortDesc] = useState(true);
const [defaultSortApplied, setDefaultSortApplied] = useState(false);
const [brandingSettings, setBrandingSettings] = useState<any>(null);
const [showUploadModal, setShowUploadModal] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -101,6 +127,16 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
}, [data?.event?.protection_level]);
// Apply default photo sort from event settings
useEffect(() => {
if (!defaultSortApplied && data?.event?.default_photo_sort) {
const { sortBy: defaultSortBy, sortDesc: defaultSortDesc } = parseDefaultPhotoSort(data.event.default_photo_sort);
setSortBy(defaultSortBy);
setSortDesc(defaultSortDesc);
setDefaultSortApplied(true);
}
}, [data?.event?.default_photo_sort, defaultSortApplied]);
// Get individual protection settings from event
const disableRightClick = data?.event?.disable_right_click === true;
const enableDevtoolsProtection = data?.event?.enable_devtools_protection === true;
@@ -280,7 +316,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
if (settingsData && data?.event) {
let themeToApply = null;
const fullEvent = data.event; // Use the full event data from API
if (fullEvent.color_theme) {
try {
// Check if it's a valid JSON string
@@ -310,7 +346,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
// No event theme, use global theme
themeToApply = settingsData.theme_config;
}
// Apply theme with a small delay to ensure it overrides any global theme
if (themeToApply) {
// Use setTimeout to ensure this runs after any global theme application
@@ -325,12 +361,43 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
setTheme(themeToApply);
}, 0);
return () => clearTimeout(timer);
}
}
}, [settingsData, data, setTheme]); // Use data instead of event prop
// Client visibility toggle handler (#172)
const handleToggleVisibility = async (photoId: number, currentVisibility: string) => {
const newVisibility = currentVisibility === 'hidden' ? 'visible' : 'hidden';
try {
await galleryService.togglePhotoVisibility(slug, photoId, newVisibility);
queryClient.invalidateQueries({ queryKey: ['gallery-photos', slug] });
} catch (error) {
console.error('Failed to toggle visibility:', error);
}
};
const handleBulkVisibility = async (visibility: 'visible' | 'hidden') => {
if (selectedPhotos.size === 0) return;
try {
await galleryService.bulkToggleVisibility(slug, Array.from(selectedPhotos), visibility);
setSelectedPhotos(new Set());
setIsSelectionMode(false);
queryClient.invalidateQueries({ queryKey: ['gallery-photos', slug] });
} catch (error) {
console.error('Failed to bulk toggle visibility:', error);
}
};
// Client visibility stats
const visibleCount = useMemo(() => {
if (!isClient || !data?.photos) return 0;
return data.photos.filter(p => p.visibility !== 'hidden').length;
}, [isClient, data?.photos]);
const totalCount = data?.photos?.length || 0;
// Calculate days until expiration (null means never expires)
const daysUntilExpiration = event.expires_at
? differenceInDays(parseISO(event.expires_at), new Date())
@@ -382,29 +449,32 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
// Apply sorting
// Each comparator defaults to its natural order (desc for dates/size/rating, asc for name).
// The flip multiplier reverses that when sortDesc differs from the natural order.
const flip = sortDesc ? 1 : -1;
photos.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.filename.localeCompare(b.filename);
// Natural order is ascending (A-Z); flip when sortDesc=true
return (sortDesc ? -1 : 1) * a.filename.localeCompare(b.filename);
case 'size':
return b.size - a.size;
case 'rating':
// Sort by rating (highest first), then by comment count
return flip * (b.size - a.size);
case 'rating': {
const ratingA = a.average_rating || 0;
const ratingB = b.average_rating || 0;
if (ratingA !== ratingB) {
return ratingB - ratingA;
return flip * (ratingB - ratingA);
}
// If ratings are equal, sort by comment count
return (b.comment_count || 0) - (a.comment_count || 0);
case 'capture_date':
// Sort by capture date (from EXIF), fall back to upload date
return flip * ((b.comment_count || 0) - (a.comment_count || 0));
}
case 'capture_date': {
const captureDateA = a.captured_at || a.uploaded_at;
const captureDateB = b.captured_at || b.uploaded_at;
return new Date(captureDateB).getTime() - new Date(captureDateA).getTime();
return flip * (new Date(captureDateB).getTime() - new Date(captureDateA).getTime());
}
case 'date':
default:
return new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime();
return flip * (new Date(b.uploaded_at).getTime() - new Date(a.uploaded_at).getTime());
}
});
@@ -418,7 +488,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
return photos;
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType, mediaFilter]);
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, sortDesc, watermarkEnabled, slug, filterType, mediaFilter]);
const likeCount = useMemo(
() => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0,
@@ -619,6 +689,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
headerStyle={data?.event?.header_style || theme.headerStyle}
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
welcomeMessage={event.welcome_message}
onLogout={logout}
/>
@@ -638,8 +709,14 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
);
}
const identityMode: 'simple' | 'guest' =
feedbackSettings?.identity_mode === 'guest' ? 'guest' : 'simple';
return (
<GuestIdentityProvider slug={slug} identityMode={identityMode}>
<>
<GuestNamePromptModal requireEmail={!!feedbackSettings?.require_name_email} />
<GuestRecoveryModal />
{/* Sidebar for non-grid layouts */}
{showSidebar ? (
<GallerySidebar
@@ -733,6 +810,43 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
<ExpirationBanner daysRemaining={daysUntilExpiration} expiresAt={event.expires_at} />
)}
{/* Client Access Banner (#172) */}
{isClient && (
<div className="mt-4 rounded-lg border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 p-4">
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-amber-600 dark:text-amber-400" />
<span className="text-sm font-medium text-amber-800 dark:text-amber-200">
{t('clientAccess.banner')}
</span>
<span className="text-xs text-amber-600 dark:text-amber-400 ml-2">
{t('clientAccess.visibleCount', { visible: visibleCount, total: totalCount })}
</span>
</div>
{isSelectionMode && selectedPhotos.size > 0 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
leftIcon={<EyeOff className="w-4 h-4" />}
onClick={() => handleBulkVisibility('hidden')}
>
{t('clientAccess.hideSelected')}
</Button>
<Button
variant="outline"
size="sm"
leftIcon={<Eye className="w-4 h-4" />}
onClick={() => handleBulkVisibility('visible')}
>
{t('clientAccess.showSelected')}
</Button>
</div>
)}
</div>
</div>
)}
{/* Search and Filters - Only for grid layout */}
{!showSidebar ? (
<div className="mt-6">
@@ -794,6 +908,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
headerStyle={data?.event?.header_style || theme.headerStyle}
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
welcomeMessage={event.welcome_message}
isClient={isClient}
onToggleVisibility={isClient ? handleToggleVisibility : undefined}
/>
</div>
@@ -812,5 +929,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
)}
</GalleryLayout>
</>
</GuestIdentityProvider>
);
};
@@ -0,0 +1,156 @@
import React, { useState } from 'react';
import { X } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../common';
import { useGuestIdentity } from '../../contexts/GuestIdentityContext';
interface GuestNamePromptModalProps {
requireEmail?: boolean;
allowCancel?: boolean;
onCancel?: () => void;
}
/**
* Session-wide prompt shown in guest identity mode when no identity exists
* yet. Triggered by `ensureIdentity()` on the first interactive feedback
* attempt, or manually via `openPrompt()`.
*
* Includes a link to the recovery flow for users who already registered on
* another device.
*/
export const GuestNamePromptModal: React.FC<GuestNamePromptModalProps> = ({
requireEmail = false,
allowCancel = true,
onCancel,
}) => {
const { t } = useTranslation();
const { promptOpen, closePrompt, register, openRecovery } = useGuestIdentity();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<Record<string, string>>({});
const [submitting, setSubmitting] = useState(false);
const [submitError, setSubmitError] = useState<string | null>(null);
if (!promptOpen) return null;
const handleClose = () => {
setName('');
setEmail('');
setErrors({});
setSubmitError(null);
closePrompt();
onCancel?.();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const newErrors: Record<string, string> = {};
if (!name.trim()) {
newErrors.name = t('gallery.guestPrompt.nameRequired', 'Name is required');
}
if (email && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
newErrors.email = t('gallery.guestPrompt.invalidEmail', 'Invalid email address');
}
if (requireEmail && !email.trim()) {
newErrors.email = t('gallery.guestPrompt.emailRequired', 'Email is required');
}
if (Object.keys(newErrors).length > 0) {
setErrors(newErrors);
return;
}
setSubmitting(true);
setSubmitError(null);
try {
await register(name.trim(), email.trim() || undefined);
} catch (err) {
const error = err as { response?: { data?: { error?: string } } };
setSubmitError(error.response?.data?.error || t('gallery.guestPrompt.error', 'Registration failed'));
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={allowCancel ? handleClose : undefined} />
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
{allowCancel && (
<button
type="button"
onClick={handleClose}
className="absolute top-4 right-4 p-1 hover:bg-black/10 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-muted-theme" />
</button>
)}
<h2 className="text-lg font-semibold text-theme mb-2">
{t('gallery.guestPrompt.title', "Welcome — what's your name?")}
</h2>
<p className="text-sm text-muted-theme mb-4">
{t(
'gallery.guestPrompt.description',
'Your picks will be saved under this name so the photographer knows which photos you love.'
)}
</p>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
label={t('gallery.guestPrompt.nameLabel', 'Your name')}
value={name}
onChange={(e) => setName(e.target.value)}
error={errors.name}
placeholder={t('gallery.guestPrompt.namePlaceholder', 'Enter your name')}
autoFocus
required
maxLength={100}
/>
<Input
type="email"
label={
requireEmail
? t('gallery.guestPrompt.emailLabelRequired', 'Email')
: t('gallery.guestPrompt.emailLabel', 'Email (optional)')
}
value={email}
onChange={(e) => setEmail(e.target.value)}
error={errors.email}
placeholder={t('gallery.guestPrompt.emailPlaceholder', 'you@example.com')}
maxLength={255}
/>
{submitError && (
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded px-3 py-2">
{submitError}
</div>
)}
<div className="flex gap-2 pt-2">
<Button type="submit" variant="primary" className="flex-1" disabled={submitting}>
{submitting
? t('common.submitting', 'Submitting...')
: t('gallery.guestPrompt.submit', 'Continue')}
</Button>
{allowCancel && (
<Button type="button" variant="ghost" onClick={handleClose} disabled={submitting}>
{t('common.cancel', 'Cancel')}
</Button>
)}
</div>
<button
type="button"
onClick={() => {
closePrompt();
openRecovery();
}}
className="text-sm text-primary-600 hover:underline w-full text-center pt-2"
>
{t('gallery.guestPrompt.alreadyHere', "I've been here before")}
</button>
</form>
</div>
</div>
);
};
@@ -0,0 +1,173 @@
import React, { useState } from 'react';
import { X, ArrowLeft } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button, Input } from '../common';
import { useGuestIdentity } from '../../contexts/GuestIdentityContext';
/**
* Email-based identity recovery flow (Phase 3.2).
*
* Two steps:
* 1) Enter email server sends a 6-digit code.
* 2) Enter code server returns a guest token, identity restored.
*
* Opens when the user clicks "I've been here before" in the name prompt.
*/
export const GuestRecoveryModal: React.FC = () => {
const { t } = useTranslation();
const { recoveryOpen, closeRecovery, recoverRequest, recoverVerify, openPrompt } =
useGuestIdentity();
const [step, setStep] = useState<'email' | 'code'>('email');
const [email, setEmail] = useState('');
const [code, setCode] = useState('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [info, setInfo] = useState<string | null>(null);
if (!recoveryOpen) return null;
const reset = () => {
setStep('email');
setEmail('');
setCode('');
setSubmitting(false);
setError(null);
setInfo(null);
};
const handleClose = () => {
reset();
closeRecovery();
};
const backToPrompt = () => {
reset();
closeRecovery();
openPrompt();
};
const handleRequestCode = async (e: React.FormEvent) => {
e.preventDefault();
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
setError(t('gallery.guestRecovery.invalidEmail', 'Enter a valid email address'));
return;
}
setSubmitting(true);
setError(null);
try {
await recoverRequest(email.trim().toLowerCase());
setInfo(t('gallery.guestRecovery.codeSent', 'Check your inbox for a verification code.'));
setStep('code');
} catch {
setError(t('gallery.guestRecovery.requestError', 'Could not send code. Try again.'));
} finally {
setSubmitting(false);
}
};
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault();
if (!/^\d{6}$/.test(code.trim())) {
setError(t('gallery.guestRecovery.invalidCode', 'Enter the 6-digit code'));
return;
}
setSubmitting(true);
setError(null);
try {
await recoverVerify(email.trim().toLowerCase(), code.trim());
// Success: context clears recoveryOpen on success, component will
// unmount naturally.
} catch {
setError(t('gallery.guestRecovery.verifyError', 'Invalid or expired code.'));
} finally {
setSubmitting(false);
}
};
return (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={handleClose} />
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
<button
type="button"
onClick={handleClose}
className="absolute top-4 right-4 p-1 hover:bg-black/10 rounded-lg transition-colors"
>
<X className="w-5 h-5 text-muted-theme" />
</button>
<button
type="button"
onClick={backToPrompt}
className="flex items-center gap-1 text-sm text-muted-theme hover:text-theme mb-3"
>
<ArrowLeft className="w-4 h-4" />
{t('gallery.guestRecovery.back', 'Back')}
</button>
<h2 className="text-lg font-semibold text-theme mb-2">
{t('gallery.guestRecovery.title', 'Recover your picks')}
</h2>
<p className="text-sm text-muted-theme mb-4">
{step === 'email'
? t(
'gallery.guestRecovery.emailStepDescription',
'Enter the email you used before. We will send a 6-digit verification code.'
)
: t(
'gallery.guestRecovery.codeStepDescription',
'Enter the 6-digit code we sent to your email.'
)}
</p>
{info && step === 'code' && (
<div className="text-sm text-green-700 bg-green-50 dark:bg-green-900/20 rounded px-3 py-2 mb-3">
{info}
</div>
)}
{error && (
<div className="text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded px-3 py-2 mb-3">
{error}
</div>
)}
{step === 'email' ? (
<form onSubmit={handleRequestCode} className="space-y-4">
<Input
type="email"
label={t('gallery.guestRecovery.emailLabel', 'Email')}
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus
required
/>
<Button type="submit" variant="primary" className="w-full" disabled={submitting}>
{submitting
? t('common.submitting', 'Submitting...')
: t('gallery.guestRecovery.sendCode', 'Send code')}
</Button>
</form>
) : (
<form onSubmit={handleVerify} className="space-y-4">
<Input
label={t('gallery.guestRecovery.codeLabel', 'Verification code')}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="123456"
maxLength={6}
autoFocus
required
/>
<Button type="submit" variant="primary" className="w-full" disabled={submitting}>
{submitting
? t('common.submitting', 'Submitting...')
: t('gallery.guestRecovery.verifyCode', 'Verify and continue')}
</Button>
</form>
)}
</div>
</div>
);
};
@@ -7,6 +7,7 @@ import { toast } from 'react-toastify';
import { format } from 'date-fns';
import { Button, Input } from '../common';
import type { PhotoFeedback } from '../../services/feedback.service';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoCommentsProps {
photoId: string;
@@ -29,6 +30,8 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const guestIdentity = useGuestIdentityOptional();
const isGuestMode = guestIdentity?.identityMode === 'guest';
const [showCommentForm, setShowCommentForm] = useState(false);
const [commentText, setCommentText] = useState('');
const [guestName, setGuestName] = useState('');
@@ -78,7 +81,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
}
});
const handleSubmitComment = (e: React.FormEvent) => {
const handleSubmitComment = async (e: React.FormEvent) => {
e.preventDefault();
setErrors({});
@@ -87,7 +90,9 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
if (!commentText.trim()) {
newErrors.comment_text = t('feedback.commentRequired', 'Comment is required');
}
if (requireNameEmail) {
// In guest identity mode, name/email come from the guest token — don't
// ask for them here.
if (requireNameEmail && !isGuestMode) {
if (!guestName.trim()) {
newErrors.guest_name = t('feedback.nameRequired', 'Name is required');
}
@@ -101,6 +106,16 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
return;
}
if (isGuestMode && guestIdentity) {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
submitCommentMutation.mutate({ comment_text: commentText.trim() });
return;
}
submitCommentMutation.mutate({
comment_text: commentText.trim(),
guest_name: guestName.trim() || undefined,
@@ -140,7 +155,7 @@ export const PhotoComments: React.FC<PhotoCommentsProps> = ({
{/* Comment Form */}
{showCommentForm && (
<form onSubmit={handleSubmitComment} className="space-y-3 p-4 bg-surface rounded-lg border border-surface">
{requireNameEmail && (
{requireNameEmail && !isGuestMode && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input
placeholder={t('feedback.yourName', 'Your name')}
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoFavoritesProps {
photoId: string;
@@ -27,6 +28,7 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const guestIdentity = useGuestIdentityOptional();
const [isSubmitting, setIsSubmitting] = useState(false);
const [animating, setAnimating] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
@@ -68,9 +70,19 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
}
});
const handleFavoriteClick = () => {
const handleFavoriteClick = async () => {
if (!isEnabled || isSubmitting) return;
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
submitFavoriteMutation.mutate({});
return;
}
if (requireNameEmail && !savedIdentity) {
setShowIdentityModal(true);
} else {
@@ -25,8 +25,8 @@ interface PhotoFilterBarProps {
onCategoryChange: (categoryId: number | string | null) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size' | 'rating';
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating') => void;
sortBy: 'date' | 'name' | 'size' | 'rating' | 'capture_date';
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating' | 'capture_date') => void;
photoCount: number;
// Feedback filter props
feedbackEnabled?: boolean;
@@ -82,9 +82,10 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
className="w-full md:w-auto text-sm md:text-base"
>
<span className="hidden md:inline">{t('common.sortBy')} </span>
{sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') :
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') :
{sortBy === 'date' ? t('gallery.sortByDate').replace('Sort by ', '') :
sortBy === 'name' ? t('gallery.sortByName').replace('Sort by ', '') :
sortBy === 'size' ? t('gallery.sortBySize').replace('Sort by ', '') :
sortBy === 'capture_date' ? t('photoSort.dateTaken', 'Date Taken') :
t('gallery.sortByRating', 'Rating')}
</Button>
@@ -101,6 +102,17 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
>
{t('gallery.sortByDate')}
</button>
<button
onClick={() => {
onSortChange('capture_date');
setShowSortMenu(false);
}}
className={`w-full text-left px-4 py-2 text-sm hover:bg-black/10 ${
sortBy === 'capture_date' ? 'text-primary-600 bg-primary-50' : 'text-muted-theme'
}`}
>
{t('photoSort.dateTaken', 'Date Taken')}
</button>
<button
onClick={() => {
onSortChange('name');
@@ -64,8 +64,13 @@ interface PhotoGridWithLayoutsProps {
heroDividerStyle?: HeroDividerStyle;
// Hero image anchor position (#162) keyword or "X% Y%" focal point
heroImageAnchor?: string;
// Welcome message (per-event) for layouts that display it
welcomeMessage?: string;
// Logout callback for full-page layouts
onLogout?: () => void;
// Client visibility controls (#172)
isClient?: boolean;
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
}
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
@@ -97,7 +102,10 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
headerStyle,
heroDividerStyle = 'wave',
heroImageAnchor = 'center',
onLogout
welcomeMessage,
onLogout,
isClient = false,
onToggleVisibility
}) => {
const { t } = useTranslation();
const { theme } = useTheme();
@@ -226,7 +234,10 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
heroLogoVisible,
heroLogoSize,
heroLogoPosition,
welcomeMessage,
onLogout,
isClient,
onToggleVisibility,
};
// Determine if we should show hero header (decoupled from layout)
@@ -284,6 +295,13 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
/>
)}
{/* Welcome Message - shown for non-fullpage layouts when set */}
{!isFullPageLayout && welcomeMessage && (
<div className="mb-6 px-4 py-3 rounded-lg bg-card-theme/50 border border-border-theme text-center">
<p className="text-sm text-muted-theme whitespace-pre-line">{welcomeMessage}</p>
</div>
)}
{/* Selection Mode Controls - Not shown for carousel, full-page layouts, or when controls are hidden */}
{showSelectionControls && photos.length > 1 && galleryLayout !== 'carousel' && !isFullPageLayout && (
<div className="mb-4 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
@@ -8,6 +8,7 @@ import { PhotoFeedback } from './PhotoFeedback';
import { feedbackService } from '../../services/feedback.service';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { VideoPlayer } from './VideoPlayer';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoLightboxProps {
photos: Photo[];
@@ -63,6 +64,8 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
const [imageLoaded, setImageLoaded] = useState(false);
const guestIdentity = useGuestIdentityOptional();
const isGuestMode = guestIdentity?.identityMode === 'guest';
useEffect(() => {
const onResize = () => setIsSmallScreen(window.innerWidth < 640);
@@ -203,6 +206,33 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
}, [slug, currentPhoto.id, feedbackSettings?.feedback_enabled]);
const submitLike = async () => {
// Guest identity mode: ensure we have a per-person guest token. The
// server reads name/email from the token — body values are ignored.
if (isGuestMode && guestIdentity) {
try {
await guestIdentity.ensureIdentity();
} catch {
// User cancelled the prompt — abort silently.
return;
}
try {
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
feedback_type: 'like',
});
setMyLiked(prev => {
const next = !prev;
setLikeCount(c => Math.max(0, c + (next ? 1 : -1)));
return next;
});
if (onFeedbackChange) onFeedbackChange();
} catch (err) {
// eslint-disable-next-line no-console
console.warn('Like submit failed', err);
}
return;
}
// Simple mode: legacy inline identity modal flow.
const needIdentity = feedbackSettings?.require_name_email && !savedIdentity;
if (needIdentity) {
setPendingAction({ type: 'like' });
@@ -222,6 +252,33 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
};
const submitRating = async (value: number) => {
// Guest identity mode.
if (isGuestMode && guestIdentity) {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
try {
await feedbackService.submitFeedback(slug, String(currentPhoto.id), {
feedback_type: 'rating',
rating: value,
});
setMyRating(value);
try {
const fresh = await feedbackService.getPhotoFeedback(slug, String(currentPhoto.id));
setAvgRating(Number(fresh.summary?.average_rating) || 0);
setTotalRatings(Number(fresh.summary?.total_ratings) || 0);
} catch {}
if (onFeedbackChange) onFeedbackChange();
} catch (err) {
// eslint-disable-next-line no-console
console.warn('Rating submit failed', err);
}
return;
}
// Simple mode: legacy inline identity modal flow.
const needIdentity = feedbackSettings?.require_name_email && !savedIdentity;
if (needIdentity) {
setPendingAction({ type: 'rating', rating: value });
+18 -2
View File
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoLikesProps {
photoId: string;
@@ -27,6 +28,7 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
}) => {
const { t } = useTranslation();
const queryClient = useQueryClient();
const guestIdentity = useGuestIdentityOptional();
const [isSubmitting, setIsSubmitting] = useState(false);
const [animating, setAnimating] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
@@ -68,9 +70,23 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
}
});
const handleLikeClick = () => {
const handleLikeClick = async () => {
if (!isEnabled || isSubmitting) return;
// Guest identity mode: ensure we have a per-person guest token. The
// server will read name/email from the token — body values are ignored.
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
// User cancelled the prompt — silently abort.
return;
}
submitLikeMutation.mutate({});
return;
}
// Simple mode (or no provider at all): legacy inline prompt flow.
if (requireNameEmail && !savedIdentity) {
setShowIdentityModal(true);
} else {
@@ -5,6 +5,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { feedbackService } from '../../services/feedback.service';
import { toast } from 'react-toastify';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
import { useGuestIdentityOptional } from '../../contexts/GuestIdentityContext';
interface PhotoRatingProps {
photoId: string;
@@ -31,6 +32,7 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
const safeAverageRating = typeof averageRating === 'number' && !isNaN(averageRating) ? averageRating : 0;
const { t } = useTranslation();
const queryClient = useQueryClient();
const guestIdentity = useGuestIdentityOptional();
const [hoveredRating, setHoveredRating] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showIdentityModal, setShowIdentityModal] = useState(false);
@@ -72,18 +74,28 @@ export const PhotoRating: React.FC<PhotoRatingProps> = ({
}
});
const handleRatingClick = (rating: number) => {
const handleRatingClick = async (rating: number) => {
if (!isEnabled || isSubmitting) return;
// If clicking the same rating, remove it
const newRating = rating === currentRating ? 0 : rating;
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
submitRatingMutation.mutate({ rating: newRating });
return;
}
if (requireNameEmail && !savedIdentity) {
setPendingRating(newRating);
setShowIdentityModal(true);
} else {
submitRatingMutation.mutate({
rating: newRating,
submitRatingMutation.mutate({
rating: newRating,
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email
});
@@ -33,6 +33,9 @@ export interface BaseGalleryLayoutProps {
};
// Logout callback for full-page layouts
onLogout?: () => void;
// Client visibility controls (#172)
isClient?: boolean;
onToggleVisibility?: (photoId: number, currentVisibility: string) => void;
}
export abstract class BaseGalleryLayout<T extends BaseGalleryLayoutProps = BaseGalleryLayoutProps> extends React.Component<T> {
@@ -5,6 +5,7 @@ import { AuthenticatedImage, Button } from '../../common';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
@@ -66,6 +67,7 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional();
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
@@ -150,6 +152,20 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
variant="ghost"
size="sm"
onClick={async () => {
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
setLikedIds(prev => new Set(prev).add(currentPhoto.id));
try {
await feedbackService.submitFeedback(slug!, String(currentPhoto.id), {
feedback_type: 'like',
});
} catch (_) {}
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: currentPhoto.id });
setShowIdentityModal(true);
@@ -17,6 +17,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
import { AuthenticatedImage } from '../../common';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import { FeedbackIdentityModal } from '../FeedbackIdentityModal';
import { galleryService } from '../../../services/gallery.service';
import { analyticsService } from '../../../services/analytics.service';
@@ -188,6 +189,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional();
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingLikePhotoId, setPendingLikePhotoId] = useState<number | null>(null);
@@ -237,6 +239,28 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
const handleLike = useCallback(async (photo: Photo, e: React.MouseEvent) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
setLikedPhotoIds(prev => {
const next = new Set(prev);
next.add(photo.id);
return next;
});
try {
await feedbackService.submitFeedback(slug, String(photo.id), {
feedback_type: 'like',
});
onFeedbackChange?.();
} catch (err) {
console.warn('Like submit failed', err);
}
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingLikePhotoId(photo.id);
setShowIdentityModal(true);
@@ -260,7 +284,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
} catch (err) {
console.warn('Like submit failed', err);
}
}, [slug, savedIdentity, feedbackOptions, onFeedbackChange]);
}, [slug, savedIdentity, feedbackOptions, onFeedbackChange, guestIdentity]);
const handleIdentitySubmit = useCallback(async (name: string, email: string) => {
setSavedIdentity({ name, email });
@@ -343,7 +367,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
<div
className="gallery-premium-hero-bg"
style={{
backgroundImage: heroPhoto ? `url(${heroPhoto.thumbnail_url || heroPhoto.url})` : undefined
backgroundImage: heroPhoto ? `url(${heroPhoto.hero_url || heroPhoto.url})` : undefined
}}
/>
<div className="gallery-premium-hero-overlay" />
@@ -17,6 +17,7 @@ import {
StoryFeedbackSheet,
StoryScrollToTop
} from './story';
import { PhotoLightbox } from '../PhotoLightbox';
import './GalleryStoryLayout.css';
@@ -34,6 +35,7 @@ interface CategoryScene {
interface GalleryStoryLayoutProps extends BaseGalleryLayoutProps {
heroPhotoOverride?: Photo | null;
welcomeMessage?: string;
}
export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
@@ -55,6 +57,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
feedbackEnabled = false,
feedbackOptions,
heroPhotoOverride,
welcomeMessage,
onLogout
}) => {
// These props are passed by parent but we use our own feedback system, so mark as intentionally unused
@@ -69,6 +72,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
const [searchQuery, setSearchQuery] = useState('');
const [favorites, setFavorites] = useState<Set<number>>(new Set());
const [selectedPhotoForFeedback, setSelectedPhotoForFeedback] = useState<Photo | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [comments, setComments] = useState<Record<number, Array<{ id: string; author: string; text: string; date: string }>>>({});
const [ratings, setRatings] = useState<Record<number, number>>({});
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
@@ -110,7 +114,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
// Group by category
filteredPhotos.forEach(photo => {
const categoryName = photo.category_name || t('gallery.uncategorized', 'Gallery');
const categoryName = photo.category_name || '';
if (!photosByCategory[categoryName]) {
photosByCategory[categoryName] = [];
}
@@ -161,6 +165,11 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
setSelectedPhotoForFeedback(photo);
}, []);
const handleOpenLightbox = useCallback((photo: Photo) => {
const index = photos.findIndex(p => p.id === photo.id);
setLightboxIndex(index >= 0 ? index : 0);
}, [photos]);
const handleCloseFeedback = useCallback(() => {
setSelectedPhotoForFeedback(null);
}, []);
@@ -310,7 +319,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
photos={scene.photos}
favorites={favorites}
onToggleFavorite={handleToggleFavorite}
onPhotoClick={handleOpenFeedback}
onPhotoClick={handleOpenLightbox}
slug={slug}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
@@ -326,7 +335,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
index={index}
isFavorite={favorites.has(photo.id)}
onToggleFavorite={handleToggleFavorite}
onClick={() => handleOpenFeedback(photo)}
onClick={() => handleOpenLightbox(photo)}
slug={slug}
galleryId={`gallery-${scene.id}`}
allowDownloads={allowDownloads}
@@ -348,7 +357,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
<footer className="story-footer">
<h2 className="story-footer-title">{t('gallery.thankYou', 'Thank You')}</h2>
<p className="story-footer-text">
{t('gallery.thankYouMessage', 'For being part of our story and making our special day unforgettable.')}
{welcomeMessage || t('gallery.thankYouMessage', 'For being part of our story and making our special day unforgettable.')}
</p>
{allowDownloads && (
<button className="story-footer-btn" onClick={handleDownloadAll}>
@@ -357,6 +366,22 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
)}
</footer>
{/* Lightbox */}
{lightboxIndex !== null && (
<PhotoLightbox
photos={photos}
initialIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
slug={slug}
feedbackEnabled={feedbackEnabled}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
onFeedbackChange={onFeedbackChange}
/>
)}
{/* Feedback Sheet */}
{feedbackEnabled && (
<StoryFeedbackSheet
@@ -1,11 +1,12 @@
import React from 'react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video } from 'lucide-react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video, Eye, EyeOff } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { useTranslation } from 'react-i18next';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
@@ -61,6 +62,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
onLikeSuccess
}) => {
const { t } = useTranslation();
const guestIdentity = useGuestIdentityOptional();
const [overlayVisible, setOverlayVisible] = React.useState(false);
const [isTouchDevice, setIsTouchDevice] = React.useState(false);
const overlayTimeoutRef = React.useRef<number | null>(null);
@@ -259,6 +261,25 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
className={`p-2 rounded-full transition-colors ${liked ? 'bg-red-500/90 hover:bg-red-500' : 'bg-white/90 hover:bg-white'}`}
onClick={async (e) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
hideOverlay();
return;
}
if (onLikeSuccess) onLikeSuccess();
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
});
} catch (err) {
console.warn('Like submit failed, keeping optimistic UI', err);
}
if (onFeedbackChange) onFeedbackChange();
hideOverlay();
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
onRequireIdentity('like', photo.id);
hideOverlay();
@@ -365,13 +386,19 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
useEnhancedProtection = false,
useCanvasRendering = false,
feedbackEnabled = false,
feedbackOptions
feedbackOptions,
isClient = false,
onToggleVisibility
}) => {
const { theme } = useTheme();
const gallerySettings = theme.gallerySettings || {};
const columns = gallerySettings.gridColumns || { mobile: 2, tablet: 3, desktop: 4 };
const spacing = gallerySettings.spacing || 'normal';
const animation = gallerySettings.photoAnimation || 'fade';
const scale = gallerySettings.thumbnailScale || 'md';
const scaleOffsets: Record<string, number> = { xs: 3, sm: 1, md: 0, lg: -1, xl: -2 };
const applyScale = (cols: number) => Math.max(1, cols + (scaleOffsets[scale] ?? 0));
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
@@ -379,49 +406,70 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
const gridClass = `photo-grid grid ${spacingClass}
grid-cols-${columns.mobile}
sm:grid-cols-${columns.tablet}
lg:grid-cols-${columns.desktop}
xl:grid-cols-${columns.desktop + 1}`;
grid-cols-${applyScale(columns.mobile)}
sm:grid-cols-${applyScale(columns.tablet)}
lg:grid-cols-${applyScale(columns.desktop)}
xl:grid-cols-${applyScale(columns.desktop + 1)}`;
return (
<div className={gridClass}>
{photos.map((photo, index) => (
<GridPhoto
key={photo.id}
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(index)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
onDownload={(e) => onDownload(photo, e)}
animationType={animation}
allowDownloads={allowDownloads}
slug={slug}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
savedIdentity={savedIdentity}
onRequireIdentity={(action, photoId) => {
setPendingAction({ type: action, photoId });
setShowIdentityModal(true);
}}
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(index)}
onFeedbackChange={onFeedbackChange}
liked={likedPhotoIds.has(photo.id)}
onLikeSuccess={() => {
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(photo.id);
return next;
});
}}
/>
))}
{photos.map((photo, index) => {
const isHidden = photo.visibility === 'hidden';
return (
<div key={photo.id} className={`relative ${isClient && isHidden ? 'opacity-40' : ''}`}>
<GridPhoto
photo={photo}
isSelected={selectedPhotos.has(photo.id)}
isSelectionMode={isSelectionMode}
onClick={() => onPhotoClick(index)}
onToggleSelect={() => onPhotoSelect && onPhotoSelect(photo.id)}
onDownload={(e) => onDownload(photo, e)}
animationType={animation}
allowDownloads={allowDownloads}
slug={slug}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
feedbackEnabled={feedbackEnabled}
feedbackOptions={feedbackOptions}
savedIdentity={savedIdentity}
onRequireIdentity={(action, photoId) => {
setPendingAction({ type: action, photoId });
setShowIdentityModal(true);
}}
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(index)}
onFeedbackChange={onFeedbackChange}
liked={likedPhotoIds.has(photo.id)}
onLikeSuccess={() => {
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(photo.id);
return next;
});
}}
/>
{/* Client visibility toggle overlay (#172) */}
{isClient && onToggleVisibility && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleVisibility(photo.id, photo.visibility || 'visible');
}}
className={`absolute top-2 left-2 z-10 p-1.5 rounded-full shadow-md transition-colors ${
isHidden
? 'bg-red-500/90 text-white hover:bg-red-600'
: 'bg-white/90 text-neutral-700 hover:bg-white dark:bg-neutral-800/90 dark:text-neutral-200 dark:hover:bg-neutral-700'
}`}
title={isHidden ? 'Hidden from guests' : 'Visible to guests'}
>
{isHidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
)}
</div>
);
})}
<FeedbackIdentityModal
isOpen={showIdentityModal}
onClose={() => { setShowIdentityModal(false); setPendingAction(null); }}
@@ -8,6 +8,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import { buildResourceUrl } from '../../../utils/url';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
@@ -81,6 +82,7 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
liked = false,
onLikeSuccess,
}) => {
const guestIdentity = useGuestIdentityOptional();
const [overlayVisible, setOverlayVisible] = useState(false);
const [isTouchDevice, setIsTouchDevice] = useState(false);
const overlayTimeoutRef = useRef<number | null>(null);
@@ -301,6 +303,25 @@ const JustifiedPhoto: React.FC<JustifiedPhotoProps> = ({
}`}
onClick={async (e) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
hideOverlay();
return;
}
if (onLikeSuccess) onLikeSuccess();
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
});
} catch (err) {
console.warn('Like submit failed, keeping optimistic UI', err);
}
if (onFeedbackChange) onFeedbackChange();
hideOverlay();
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity && onRequireIdentity) {
onRequireIdentity('like', photo.id);
hideOverlay();
@@ -4,6 +4,7 @@ import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import {
calculateJustifiedLayout,
createJustifiedPhotos,
@@ -33,6 +34,9 @@ interface MasonryPhotoProps {
onQuickComment?: () => void;
// Column width for calculating proper aspect-ratio-based height
columnWidth?: number;
// Optimistic "I liked this" state + callback (lifted to parent)
liked?: boolean;
onLikeSuccess?: () => void;
}
const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
@@ -48,11 +52,14 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
slug,
feedbackOptions,
onQuickComment,
columnWidth = 300
columnWidth = 300,
liked = false,
onLikeSuccess,
}) => {
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional();
// Calculate height based on actual photo aspect ratio
// This preserves the photo's natural proportions in the masonry layout
@@ -148,24 +155,56 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
)}
{feedbackEnabled && feedbackOptions?.allowLikes && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
className={`p-2 rounded-full transition-colors ${
liked
? 'bg-red-500/90 hover:bg-red-500'
: 'bg-white/90 hover:bg-white'
}`}
onClick={async (e) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
// Optimistic UI: mark as liked immediately
if (onLikeSuccess) onLikeSuccess();
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
});
} catch (err) {
// eslint-disable-next-line no-console
console.warn('Like submit failed, keeping optimistic UI', err);
}
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
return;
}
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
// Optimistic UI: mark as liked immediately
if (onLikeSuccess) onLikeSuccess();
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
guest_name: savedIdentity?.name,
guest_email: savedIdentity?.email,
});
} catch (err) {
// eslint-disable-next-line no-console
console.warn('Like submit failed, keeping optimistic UI', err);
}
}}
aria-label="Like photo"
title="Like"
aria-label={liked ? 'Unlike photo' : 'Like photo'}
aria-pressed={liked}
title={liked ? 'Unlike' : 'Like'}
>
<Heart className="w-5 h-5 text-neutral-800" />
<Heart
className={`w-5 h-5 ${liked ? 'text-white fill-white' : 'text-neutral-800'}`}
/>
</button>
)}
</>
@@ -180,6 +219,9 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
setSavedIdentity({ name, email });
setShowIdentityModal(false);
if (pendingAction) {
if (pendingAction.type === 'like' && onLikeSuccess) {
onLikeSuccess();
}
await feedbackService.submitFeedback(slug!, String(pendingAction.photoId), {
feedback_type: pendingAction.type,
guest_name: name,
@@ -236,11 +278,21 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const containerRef = useRef<HTMLDivElement>(null);
const [columns, setColumns] = useState(3);
const [containerWidth, setContainerWidth] = useState(0);
// Optimistic "I liked this" state — lifted here so it survives re-renders
// of individual MasonryPhoto components during layout reflow/resize.
const [likedPhotoIds, setLikedPhotoIds] = useState<Set<number>>(new Set());
const gallerySettings = theme.gallerySettings || {};
const gutter = gallerySettings.masonryGutter || 16;
const mode = gallerySettings.masonryMode || 'columns';
const targetRowHeight = gallerySettings.masonryRowHeight || 250;
const lastRowBehavior = gallerySettings.masonryLastRowBehavior || 'left';
const scale = gallerySettings.thumbnailScale || 'md';
const scaleOffsets: Record<string, number> = { xs: 3, sm: 1, md: 0, lg: -1, xl: -2 };
const applyScale = (cols: number) => Math.max(1, cols + (scaleOffsets[scale] ?? 0));
// Apply scale to columns only in columns mode
const scaledColumns = mode === 'columns' ? applyScale(columns) : columns;
// Calculate number of columns based on container width (for columns mode)
@@ -339,20 +391,20 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
// This creates a more balanced masonry layout instead of round-robin
const photoColumns: Photo[][] = useMemo(() => {
if (mode !== 'columns' || photos.length === 0) {
return Array.from({ length: columns }, () => []);
return Array.from({ length: scaledColumns }, () => []);
}
const cols: Photo[][] = Array.from({ length: columns }, () => []);
const colHeights: number[] = Array(columns).fill(0);
const cols: Photo[][] = Array.from({ length: scaledColumns }, () => []);
const colHeights: number[] = Array(scaledColumns).fill(0);
// Calculate approximate column width for height estimation
const approxColWidth = containerWidth > 0 ? (containerWidth - (columns - 1) * gutter) / columns : 300;
const approxColWidth = containerWidth > 0 ? (containerWidth - (scaledColumns - 1) * gutter) / scaledColumns : 300;
photos.forEach((photo) => {
// Find the shortest column
let shortestCol = 0;
let minHeight = colHeights[0];
for (let i = 1; i < columns; i++) {
for (let i = 1; i < scaledColumns; i++) {
if (colHeights[i] < minHeight) {
minHeight = colHeights[i];
shortestCol = i;
@@ -373,15 +425,15 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
});
return cols;
}, [mode, photos, columns, containerWidth, gutter]);
}, [mode, photos, scaledColumns, containerWidth, gutter]);
// Calculate approximate column width for aspect ratio calculations
const columnWidth = useMemo(() => {
if (containerWidth <= 0 || columns <= 0) return 300;
if (containerWidth <= 0 || scaledColumns <= 0) return 300;
// Account for gaps between columns
const totalGaps = (columns - 1) * gutter;
return (containerWidth - totalGaps) / columns;
}, [containerWidth, columns, gutter]);
const totalGaps = (scaledColumns - 1) * gutter;
return (containerWidth - totalGaps) / scaledColumns;
}, [containerWidth, scaledColumns, gutter]);
// ROWS MODE - Google Photos style justified layout
if (mode === 'rows') {
@@ -778,6 +830,14 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
feedbackOptions={feedbackOptions}
onQuickComment={() => onOpenPhotoWithFeedback && onOpenPhotoWithFeedback(originalIndex)}
columnWidth={columnWidth}
liked={likedPhotoIds.has(photo.id)}
onLikeSuccess={() => {
setLikedPhotoIds((prev) => {
const next = new Set(prev);
next.add(photo.id);
return next;
});
}}
/>
);
})}
@@ -1,8 +1,10 @@
import React from 'react';
import { Download, Maximize2, Check, Heart, MessageSquare } from 'lucide-react';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
@@ -52,6 +54,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
const [showIdentityModal, setShowIdentityModal] = React.useState(false);
const [pendingAction, setPendingAction] = React.useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = React.useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional();
const [likedLocal, setLikedLocal] = React.useState(false);
const canComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onQuickComment);
@@ -107,6 +110,20 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
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) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
setLikedLocal(true);
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
});
} catch (_) {}
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
@@ -210,23 +227,33 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
feedbackEnabled = false,
feedbackOptions
}) => {
const { theme } = useTheme();
const scale = theme.gallerySettings?.thumbnailScale || 'md';
const scaleOffsets: Record<string, number> = { xs: 3, sm: 1, md: 0, lg: -1, xl: -2 };
const applyScale = (cols: number, min = 1) => Math.max(min, cols + (scaleOffsets[scale] ?? 0));
const desktop = applyScale(4);
const xlDown = applyScale(3);
const lgDown = applyScale(2);
const mobile = Math.min(applyScale(1), 2); // Cap mobile at 2
return (
<div
className="photo-grid w-full"
style={{
columnCount: 4,
columnCount: desktop,
columnGap: '8px',
}}
>
<style>{`
@media (max-width: 1280px) {
.photo-grid { column-count: 3 !important; }
.photo-grid { column-count: ${xlDown} !important; }
}
@media (max-width: 1024px) {
.photo-grid { column-count: 2 !important; }
.photo-grid { column-count: ${lgDown} !important; }
}
@media (max-width: 640px) {
.photo-grid { column-count: 1 !important; }
.photo-grid { column-count: ${mobile} !important; }
}
`}</style>
{photos.map((photo, index) => (
@@ -7,6 +7,7 @@ import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
import type { Photo } from '../../../types';
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
import { feedbackService } from '../../../services/feedback.service';
import { useGuestIdentityOptional } from '../../../contexts/GuestIdentityContext';
export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
photos,
@@ -26,6 +27,7 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
const [showIdentityModal, setShowIdentityModal] = useState(false);
const [pendingAction, setPendingAction] = useState<null | { type: 'like'; photoId: number }>(null);
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
const guestIdentity = useGuestIdentityOptional();
const gallerySettings = theme.gallerySettings || {};
const grouping = gallerySettings.timelineGrouping || 'day';
const showDates = gallerySettings.timelineShowDates !== false;
@@ -147,6 +149,20 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
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) => {
e.stopPropagation();
if (guestIdentity?.identityMode === 'guest') {
try {
await guestIdentity.ensureIdentity();
} catch {
return;
}
setLikedIds(prev => new Set(prev).add(photo.id));
try {
await feedbackService.submitFeedback(slug!, String(photo.id), {
feedback_type: 'like',
});
} catch (_) {}
return;
}
if (feedbackOptions?.requireNameEmail && !savedIdentity) {
setPendingAction({ type: 'like', photoId: photo.id });
setShowIdentityModal(true);
@@ -60,7 +60,7 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
className="block w-full h-full"
>
<AuthenticatedImage
src={photo.thumbnail_url || photo.url}
src={photo.url}
alt={photo.filename}
onLoad={() => setIsLoaded(true)}
className={`w-full h-full object-cover transition-all duration-700 ease-out will-change-transform ${

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