Compare commits

...

256 Commits

Author SHA1 Message Date
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 52ab609597 i18n: add missing Russian translations for thumbnails and photo dimensions
Adds 38 missing keys for settings.thumbnails and settings.photoDimensions
that were added after the initial Russian localization PR (#216).
2026-03-15 19:48:41 +01:00
Paul Nothaft fafcfbf4e6 Merge pull request #216 from Ih0rd/russian-localization
basic Russian localization
2026-03-15 19:47:25 +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
Paul Nothaft 56f497c5f1 Merge pull request #227 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.6.1
2026-03-11 21:54:39 +01:00
github-actions[bot] d2663bff81 chore(beta): release 3.17.2-beta.0 2026-03-11 19:48:06 +00:00
github-actions[bot] b52cf1f741 chore(main): release 2.6.1 2026-03-11 19:48:05 +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
Paul Nothaft 7f7736282f Merge pull request #225 from the-luap/fix/security-reporting-policy
fix: update security policy with private reporting channels
2026-03-11 20:47:41 +01:00
Paul Nothaft 67b0f32456 fix: update security policy with proper contact email and private reporting
- Replace placeholder security@example.com with info@picpeak.app
- Add GitHub Private Vulnerability Reporting links
- Update supported versions table to 2.x.x

Closes #223
2026-03-11 20:21:32 +01:00
Paul Nothaft 25b40c03b0 Merge pull request #224 from the-luap/release/beta-to-main
Merge beta into main
2026-03-11 20:19:10 +01:00
Paul Nothaft 28793bba68 Merge main into beta for release/beta-to-main 2026-03-11 20:12:52 +01:00
Paul Nothaft 4ae91142f8 Merge pull request #222 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.6.0
2026-03-11 12:01:51 +01:00
github-actions[bot] c92879fbd3 chore(main): release 2.6.0 2026-03-11 10:57:06 +00:00
Paul Nothaft a0bb080586 Merge pull request #221 from the-luap/fix/video-upload-select-all-dimensions
fix: video upload, select all, and dimension repair (#203, #220, #180)
2026-03-11 11:56:38 +01:00
Paul Nothaft fc75bcdfc3 fix: video upload media type, select all, and dimension repair (#203, #220, #180)
- Fix admin video upload missing media_type/mime_type and video processing (#203)
- Fix Gallery-Premium Select All using atomic callbacks instead of stale closure loop (#220)
- Add photo dimension repair endpoint and admin UI (#180)
- Add E2E tests for all three fixes
2026-03-11 11:50:43 +01:00
Paul Nothaft 9877f63aed Merge pull request #219 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.1-beta.0
2026-03-08 15:47:06 +01:00
github-actions[bot] 0c98c6b453 chore(beta): release 3.17.1-beta.0 2026-03-08 14:42:28 +00:00
Paul Nothaft 831ea6a3bc Merge pull request #218 from the-luap/fix/optional-email-event-creation
fix: respect optional email settings in event creation
2026-03-08 15:42:14 +01:00
Paul Nothaft 9c44a0ebfa fix: respect optional email settings in event creation (#217)
When admin/customer emails were configured as optional in Settings >
Event Creation, the backend still rejected empty values because:

1. express-validator .optional() only skips undefined, not empty strings
   — changed to .optional({ values: 'falsy' }) so "" is treated as
   absent
2. DB columns host_email and admin_email had NOT NULL constraints
   — added migration to make them nullable
3. Email queue insert crashed on null recipient_email
   — skip queuing when no customer email is provided
2026-03-08 15:36:38 +01:00
Ih0rd a840ad4594 basic Russian localization 2026-03-06 06:17:30 +03:00
Paul Nothaft 08ac238d0a Merge pull request #215 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.0-beta.0
2026-03-05 22:22:58 +01:00
github-actions[bot] 7d967a47ae chore(beta): release 3.17.0-beta.0 2026-03-05 21:21:48 +00:00
Paul Nothaft 9b7495e005 Merge pull request #214 from the-luap/feat/configurable-upload-batch-size
feat: configurable upload batch size for reverse proxy compatibility
2026-03-05 22:21:29 +01:00
Paul Nothaft e1ad4219a5 Merge pull request #212 from the-luap/revert-210-feat/configurable-upload-batch-size
Revert "feat: configurable upload batch size for reverse proxy compatibility"
2026-03-05 22:16:43 +01:00
Paul Nothaft cc4503ad28 Revert "feat: configurable upload batch size for reverse proxy compatibility" 2026-03-05 22:16:28 +01:00
Paul Nothaft 424336340b Merge pull request #210 from the-luap/feat/configurable-upload-batch-size
feat: configurable upload batch size for reverse proxy compatibility
2026-03-05 22:14:41 +01:00
Paul Nothaft a8308a5c02 Merge pull request #209 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.16.0-beta.0
2026-03-05 22:14:28 +01:00
Paul Nothaft 02a46e083d feat: add configurable upload batch size for reverse proxy compatibility (#208)
Users behind Cloudflare Tunnel and other reverse proxies cannot upload
batches >100MB. The upload chunking previously used a hardcoded 500MB
limit. This adds a configurable `max_upload_batch_size_mb` setting
(default 95MB) to the admin General settings, leaving headroom below
Cloudflare's 100MB limit.
2026-03-05 22:12:37 +01:00
github-actions[bot] 98fd6dd8e1 chore(beta): release 3.16.0-beta.0 2026-03-05 20:38:56 +00:00
Paul Nothaft 3a30fea862 Merge pull request #207 from the-luap/fix/github-issues-194-197-main
feat: add thumbnail settings UI to admin panel
2026-03-05 21:38:41 +01:00
Paul Nothaft 7d6d2f5688 feat: add thumbnail settings UI to admin settings page (#206)
Add a new "Thumbnails" tab in the admin settings page allowing users to
configure thumbnail dimensions, quality, format, and fit mode from the UI.
Also fix backend route column name mismatch (key/value → setting_key/setting_value)
that caused a 500 error, and add a button to regenerate all thumbnails.
2026-03-04 22:55:14 +01:00
Paul Nothaft b5074e4e46 Merge pull request #205 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.15.3-beta.0
2026-03-02 23:18:27 +01:00
github-actions[bot] a1d941f049 chore(beta): release 3.15.3-beta.0 2026-03-02 22:18:06 +00:00
Paul Nothaft 80171713e0 Merge pull request #204 from the-luap/fix/github-issues-194-197-main
fix: issue #203 file type validation + security CVE fixes
2026-03-02 23:17:50 +01:00
Paul Nothaft c0301dcbf4 Merge branch 'beta' into fix/github-issues-194-197-main 2026-03-02 23:15:37 +01:00
Paul Nothaft cbecb9323c fix(security): resolve Docker image CVEs for code scanning alerts
- Upgrade nginx base from 1.27-alpine to 1.28-alpine (Alpine 3.23, OpenSSL 3.5.5)
- Upgrade npm to latest in backend production stage to fix tar, minimatch, brace-expansion CVEs
- Add brace-expansion and minimatch overrides for app-level transitive deps
- Remove incompatible body-parser v2 override (breaks Express 4 JSON parsing)
- Remove npm upgrade from builder stages (npm 11 breaks npm ci with existing lockfile)
2026-03-02 23:06:15 +01:00
Paul Nothaft 4272618b3f fix(security): resolve all npm audit vulnerabilities
Frontend (6 → 0 vulnerabilities):
- axios: update to fix DoS via __proto__ key in mergeConfig (CVE-2026-25639)
- swiper: update to fix prototype pollution (critical)
- rollup: update to fix arbitrary file write via path traversal
- minimatch: update to fix multiple ReDoS vulnerabilities
- ajv: update to fix ReDoS with $data option
- markdown-it: update to fix ReDoS

Backend (32 → 0 vulnerabilities):
- multer: update to fix DoS via incomplete cleanup and resource exhaustion
- minimatch: update to fix multiple ReDoS vulnerabilities
- Add npm overrides for transitive dependencies:
  - fast-xml-parser >=5.3.8 (fixes XSS, DoS, stack overflow via AWS SDK)
  - qs >=6.14.2 (fixes arrayLimit bypass DoS via Express)
  - tar >=7.5.8 (fixes path traversal and hardlink attacks via sqlite3)

Docker:
- Pin nginx base image to 1.27-alpine in Dockerfile.prod
- Update security comments in backend Dockerfile
- Existing apk upgrade --no-cache ensures OpenSSL/libexpat CVEs are
  patched at build time (OpenSSL 3.5.5, Alpine 3.23.3)
2026-03-02 10:36:47 +01:00
Paul Nothaft fe07a148f1 fix: respect allowed_file_types setting for upload validation (#203)
The "Allowed File Types" admin setting was stored in the database but
never actually read during upload validation. Both frontend and backend
used hardcoded MIME type lists, causing video uploads (e.g. MP4) to be
rejected even when explicitly added to the setting.

Changes:
- Add getAllowedMimeTypes() to uploadSettings service that reads the
  general_allowed_file_types DB setting and converts extensions to MIME types
- Backend admin upload route now resolves allowed types from settings
  before multer processes files (via resolveAllowedTypes middleware)
- Backend gallery upload route uses dynamic allowed types from settings
- Expose allowed_file_types in public settings API for gallery clients
- Frontend PhotoUpload and UserPhotoUpload components now derive allowed
  MIME types from settings instead of hardcoded image-only lists
- Add shared fileTypes.ts utility for extension-to-MIME conversion

Closes #203
2026-03-01 14:36:34 +01:00
Paul Nothaft 0ec4190e2e Merge pull request #201 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.5.1
2026-02-23 20:10:21 +01:00
Paul Nothaft 0ec3787150 Merge pull request #200 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.15.2-beta.0
2026-02-23 20:10:12 +01:00
github-actions[bot] 59faf73f04 chore(main): release 2.5.1 2026-02-22 21:37:40 +00:00
github-actions[bot] 3e0c4fd73e chore(beta): release 3.15.2-beta.0 2026-02-22 21:37:26 +00:00
Paul Nothaft 33af088560 Merge pull request #199 from the-luap/fix/github-issues-194-197-main
fix: resolve issues #194, #195, #196, #197
2026-02-22 22:37:21 +01:00
Paul Nothaft 5ea4ef3cf3 Merge pull request #198 from the-luap/fix/github-issues-194-197
fix: resolve issues #194, #195, #196, #197
2026-02-22 22:37:11 +01:00
Paul Nothaft 33483cf32d fix: resolve issues #194, #195, #196, #197
- #194: Send full date format object instead of just format string to prevent JSON parse errors
- #195: Remove non-functional forgot password link, fix README port 3005 -> 3000
- #196: Use ADMIN_PASSWORD env var in migration, update existing user in create-admin script instead of failing
- #197: Convert camelCase filter keys to snake_case in photo export to match backend PhotoFilterBuilder
2026-02-22 22:34:44 +01:00
Paul Nothaft cd00bc13d4 fix: resolve issues #194, #195, #196, #197
- #194: Send full date format object instead of just format string to prevent JSON parse errors
- #195: Remove non-functional forgot password link, fix README port 3005 -> 3000
- #196: Use ADMIN_PASSWORD env var in migration, update existing user in create-admin script instead of failing
- #197: Convert camelCase filter keys to snake_case in photo export to match backend PhotoFilterBuilder
2026-02-22 22:27:21 +01:00
Paul Nothaft 26ec9666b9 Merge pull request #193 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.5.0
2026-02-21 20:52:30 +01:00
github-actions[bot] f672c1daa6 chore(main): release 2.5.0 2026-02-21 19:48:43 +00:00
Paul Nothaft 5f1f0f253d Merge pull request #192 from the-luap/release/beta-to-main
Release v3.15.1: Merge beta to main
2026-02-21 20:48:01 +01:00
Paul Nothaft 888c4ab209 Merge main into beta for release/beta-to-main
Resolved conflicts in CHANGELOG.md, backend/package.json, and
frontend/package.json. Version set to 3.15.1.
2026-02-21 20:43:46 +01:00
Paul Nothaft 551d9cc66f Merge pull request #191 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.15.1-beta.0
2026-02-21 20:32:31 +01:00
github-actions[bot] 9045402c9a chore(beta): release 3.15.1-beta.0 2026-02-21 19:31:37 +00:00
Paul Nothaft 0817443e79 Merge pull request #190 from the-luap/feat/new-features
fix: docker compose v2 syntax and add missing ADMIN_PASSWORD to .env.example (#189)
2026-02-21 20:31:20 +01:00
Paul Nothaft a4c624802b fix: update docker-compose to docker compose and add ADMIN_PASSWORD to .env.example (#189)
- Replace deprecated docker-compose (v1) with docker compose (v2) in README
- Add missing ADMIN_PASSWORD to .env.example so new users don't get a
  blank-string warning and can actually log in after first setup
2026-02-21 08:28:49 +01:00
Paul Nothaft 79cf4100a1 Merge pull request #188 from the-luap/release-please--branches--beta
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(beta): release 3.15.0-beta.0
2026-02-17 20:48:38 +01:00
github-actions[bot] fe9486e5fa chore(beta): release 3.15.0-beta.0 2026-02-17 19:47:41 +00:00
Paul Nothaft bcf2745ab6 Merge pull request #187 from the-luap/feat/new-features
feat: original filename in admin UI, update dialog, security hardening, and bug fixes
2026-02-17 20:47:25 +01:00
Paul Nothaft c4f16eb76c fix: events without expiration date incorrectly shown as expired
When expires_at is null (no expiration), the status logic defaulted
days to 0, causing all non-expiring events to display as "Expired".
Now returns "Active" immediately when there is no expiration date.
2026-02-17 15:48:57 +01:00
Paul Nothaft 5925ea8406 Merge pull request #186 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.14.0-beta.0
2026-02-17 15:38:34 +01:00
github-actions[bot] 6613f1b088 chore(beta): release 3.14.0-beta.0 2026-02-17 14:38:14 +00:00
Paul Nothaft 3ea9d5b121 Merge pull request #185 from the-luap/feat/new-features
feat: original filename in admin UI, update dialog, and security hardening
2026-02-17 15:37:56 +01:00
Paul Nothaft 0891be197f feat: show original filename in admin UI (#184)
Surface the existing original_filename from the database in the admin
photo grid hover overlay and photo viewer sidebar, so photographers can
correlate uploaded images with their Lightroom/disk originals. Only shown
when it differs from the system-generated filename. Gallery guests remain
unaffected.
2026-02-17 15:30:12 +01:00
Paul Nothaft 2b25d81144 security: comprehensive hardening across frontend, backend, and infrastructure
- Disable production source maps and hide nginx version
- Reduce JSON body limit from 10gb to 50mb (uploads use multer, not JSON)
- Strip database info and error details from health endpoint
- Mask reCAPTCHA secret key in admin settings API responses
- Whitelist sort/order query parameters in events and photos endpoints
- Stop reflecting arbitrary origins in static file CORS headers
- Align nginx security headers with backend Helmet CSP, remove deprecated X-XSS-Protection
- Strip EXIF metadata from generated thumbnails and hero images
- Bind postgres/redis dev ports to localhost in docker-compose configs
- Add safeExec utility (spawn with shell:false) to prevent command injection
- Convert all exec/execAsync calls in backup, restore, and database backup
  services to use safe spawn-based helpers
2026-02-16 22:33:20 +01:00
Paul Nothaft 50c09904a9 feat: add update instructions dialog, email notifications, and capture date sorting
- Add Update Instructions Dialog with environment-specific commands (Docker/Git/Standalone)
- Add email notification settings for new version alerts
- Add "Sort by Capture Date" option using EXIF metadata extraction
- Fix E2E tests by loading environment variables via dotenv
- Add test-images/ and backend/*.db to .gitignore

Closes #181
2026-02-16 16:23:57 +01:00
Paul Nothaft 7aa37b2447 Merge pull request #183 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.13.1-beta.0
2026-02-15 22:53:50 +01:00
github-actions[bot] d239857d9a chore(beta): release 3.13.1-beta.0 2026-02-15 21:49:34 +00:00
Paul Nothaft 3974ba5de5 Merge pull request #182 from the-luap/feat/new-features
fix: restore aspect-ratio layouts and improve hero image quality (#180)
2026-02-15 22:49:20 +01:00
Paul Nothaft 5cef7fdd18 fix: restore aspect-ratio layouts and improve hero image quality (#180)
- Fix masonry/mosaic layout regression where tiles displayed uniform heights
  instead of respecting image aspect ratios. Changed from fixed 150-500px
  height constraints to dynamic constraints based on column width.

- Add hero image optimization pipeline generating 1920x1080 images for
  full-width hero sections instead of using low-quality thumbnails.

- New /hero/:photoId endpoint serves optimized hero images with watermark
  support and automatic generation/caching.

- Add hero_url field to photos API response for frontend consumption.

- Migration 069 adds hero_path column to photos table.
2026-02-15 22:43:18 +01:00
Paul Nothaft 092f007ed3 Merge pull request #179 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.13.0-beta.0
2026-02-07 00:36:35 +01:00
github-actions[bot] edf3a43950 chore(beta): release 3.13.0-beta.0 2026-02-06 23:35:55 +00:00
Paul Nothaft 45d78c0dce Merge pull request #178 from the-luap/feat/new-features
feat: improve hero image UX and live preview (#163, #158)
2026-02-07 00:35:37 +01:00
Paul Nothaft d63f67a2af feat: improve hero image UX and live preview (#163, #158)
- Update hero photo help text to mention category override capability
- Add hint in category manager about default hero photo fallback
- Add placeholder text in gallery preview for hero section
- Ensure live preview updates correctly for header/divider style changes
2026-02-07 00:29:25 +01:00
Paul Nothaft ad00eae251 Merge pull request #177 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.12.0-beta.0
2026-02-06 23:31:17 +01:00
github-actions[bot] 2c35543e73 chore(beta): release 3.12.0-beta.0 2026-02-06 22:29:57 +00:00
Paul Nothaft 7c75736719 Merge pull request #176 from the-luap/feat/new-features
Feat/new features
2026-02-06 23:29:39 +01:00
Paul Nothaft 9c2a0d272a feat: add admin dark mode and SEO/robots.txt settings
Admin Dark Mode:
- Add AdminDarkModeContext with light/dark/system preference
- Update all admin components with Tailwind dark: classes
- Add dark mode toggle in admin header
- Persist preference in localStorage

SEO Settings:
- Add robots.txt configuration in Settings > SEO tab
- Block AI crawlers (GPTBot, ChatGPT-User, etc.) with toggle
- Custom robots.txt rules management
- Add RobotsMetaTags component for gallery pages
- Backend service for dynamic robots.txt generation
- Database migration for SEO settings storage

UI/UX Improvements:
- Consistent dark mode styling across all admin pages
- Update gallery components with themed CSS classes
- Fix input, card, and button styling for dark mode
2026-02-06 23:26:01 +01:00
Paul Nothaft 4912e2bccf fix: improve ghost button visibility in admin dark mode
Update ghost button variant to use proper dark mode colors:
- Add dark:hover:bg-neutral-700 for hover state
- Add dark:text-neutral-300 for better icon/text visibility
- Fixes too-dark edit and view gallery buttons in Events table
2026-02-06 23:23:34 +01:00
Paul Nothaft f8c8abd70b fix: resolve mixed light/dark mode styling in admin UI (#175)
- Update .card class to use explicit Tailwind colors instead of CSS
  variables, preventing gallery theme from affecting admin UI
- Add .card-themed and .input-themed classes for gallery components
  that need to use theme CSS variables
- Add dark mode support to CardHeader and CardFooter components
- Update .input class to use explicit colors for proper light/dark mode
- Update dark mode selectors for consistency (.dark .class)
2026-02-06 23:13:15 +01:00
Paul Nothaft 7726adeff0 Merge pull request #174 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.11.0-beta.0
2026-02-06 21:46:42 +01:00
github-actions[bot] e05fd64760 chore(beta): release 3.11.0-beta.0 2026-02-06 20:46:15 +00:00
Paul Nothaft 4280444d70 Merge pull request #173 from the-luap/feat/new-features
feat: gallery layouts, hero customization, event types, and UX improvements (#146, #155-163, #170, #171)
2026-02-06 21:45:59 +01:00
Paul Nothaft 6491184402 chore: add dependencies for Gallery Premium/Story layouts
Add missing npm packages required for new gallery layouts:
- yet-another-react-lightbox: lightbox component
- framer-motion: animations
- photoswipe: photo gallery
- swiper: carousel/slider
2026-02-06 21:43:09 +01:00
Paul Nothaft 171abb3161 fix: improve password validation errors and event list UX (#170, #171)
- Show specific failing password requirement instead of generic error
  when password validation fails on AcceptInvitePage (#170)
- Add inline Edit and View Gallery buttons to events table (#171)
- Make event table rows clickable to navigate to details (#171)
- Keep context menu for less common actions (Archive, Delete)
- Add responsive design: inline buttons hidden on mobile
2026-02-06 18:38:12 +01:00
Paul Nothaft e179def3cc feat: add Gallery Premium and Gallery Story layouts (Beta)
- Add Gallery Premium layout: elegant light theme with masonry grid,
  hero section, sticky navigation, and integrated lightbox
- Add Gallery Story layout: cinematic dark theme with scene-based
  sections, carousels, and gold accents
- Implement full-page layout support: bypass standard header/footer/
  sidebar for immersive experience
- Add logout button to both layouts for authenticated galleries
- Mark both layouts as (Beta) in theme editor and layout selectors
- Fix hero title color visibility in Gallery Premium layout
2026-02-06 18:03:47 +01:00
Paul Nothaft bc6c48bb24 fix: render minimal/none header styles, cap hero height, switch category hero images (#158, #162, #163)
- Add distinct rendering branches for minimal and none header styles in
  GalleryLayout (grid and non-grid), skipping the colored banner/wave
  divider for both
- Cap hero section height at 700px via max-h to prevent it dominating
  ultra-wide viewports
- Watch selectedCategoryId in GalleryView and swap the hero photo to
  the category's hero_photo_id when filtering, reverting to the event
  default when cleared
- Add minimal/none preview branches in GalleryPreview so the admin
  theme editor shows visually distinct previews for all four styles
- Remove unused AdminPhoto import that was blocking the build
- Add Playwright e2e tests covering all four header styles, hero max
  height, and category hero switching
2026-02-04 08:30:55 +01:00
Paul Nothaft 57845a5508 Merge pull request #167 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
2026-02-03 19:09:07 +01:00
github-actions[bot] 10ff6b118c chore(beta): release 3.10.1-beta.0 2026-02-03 16:25:51 +00:00
Paul Nothaft 2288309395 Merge pull request #166 from the-luap/feat/new-features
fix: sync header_style DB column with theme editor selections (#158)
2026-02-03 17:16:09 +01:00
Paul Nothaft a19e7c40a2 fix: sync header_style DB column with theme editor selections (#158)
The frontend never sent header_style/hero_divider_style as separate
fields when creating or updating events, so the database columns always
kept their default value of 'standard' — making the hero header
impossible to enable through the admin UI.

- Extract headerStyle/heroDividerStyle from theme config and include in
  create and update payloads (CreateEventPage, EventDetailsPage)
- Add backend fallback to extract values from color_theme JSON when not
  explicitly provided, ensuring older clients stay in sync
2026-02-03 17:12:56 +01:00
Paul Nothaft de56cd0dce Merge pull request #165 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.10.0-beta.0
2026-02-03 15:59:42 +01:00
github-actions[bot] 8ddec6ed8b chore(beta): release 3.10.0-beta.0 2026-02-03 14:56:50 +00:00
Paul Nothaft d9e00dc0db Merge pull request #164 from the-luap/feat/new-features
feat: gallery layouts, hero customization, bulk categories & event types
2026-02-03 15:50:54 +01:00
Paul Nothaft 6c30e2c2ed feat: add category hero/cover photo selection (#163)
Wire up the hero_photo_id column on photo_categories that was added in
the migration but never connected. Backend routes now accept and persist
hero_photo_id on category create/update, a dedicated PUT /:id/hero
endpoint is added, and the gallery API returns hero_photo_id for each
category. Frontend EventCategoryManager shows a clickable thumbnail per
category that opens a photo picker modal. Includes EN/DE i18n keys.
2026-02-03 15:43:23 +01:00
Paul Nothaft 329d224846 fix: resolve code quality issues and add missing i18n keys (#162, #163)
Add missing i18n translations for hero image focal point picker in both
EN and DE locales. Fix lint errors across touched files: remove unused
imports/variables, replace raw buttons with shared Button component,
eliminate inline styles, extract duplicated backend validation, and
remove dead heroImagePosition type.
2026-02-03 10:53:13 +01:00
Paul Nothaft 734868abc2 feat: add hero image focal point picker with anchor positioning (#162)
Add interactive focal point picker for hero images, allowing precise
crop positioning via click or preset buttons (top/center/bottom).
Includes backend validation, migrations, and gallery rendering support.
2026-02-03 10:08:58 +01:00
Paul Nothaft f554f463b3 fix: hero header state and preview in admin theme editor (#158)
- Add hero header rendering to GalleryPreview component with divider styles
- Support event-specific header_style prop in GalleryLayout
- Pass header_style from event data to GalleryLayout in GalleryView
- Divider options now properly show/hide when switching header styles

This ensures the live preview accurately reflects hero header changes
and event-specific header styles are respected in the gallery view.
2026-02-02 23:09:36 +01:00
Paul Nothaft fa4c83812d fix: improve photo serving, category filters, and upload chunking (#155, #156, #161)
- Add try-catch and file existence check for photo path resolution (#161)
- Fix gallery categories to use photo_categories table instead of legacy type field (#156)
- Add byte-size-based chunking (500MB max) for uploads to prevent oversized batches (#155)
2026-02-02 22:55:48 +01:00
Paul Nothaft 8cc5685428 Merge pull request #160 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.9.0-beta.0
2026-02-01 22:59:20 +01:00
github-actions[bot] 7bf1e5c0f9 chore(beta): release 3.9.0-beta.0 2026-02-01 21:58:22 +00:00
Paul Nothaft 7037106bff Merge pull request #159 from the-luap/feat/new-features
feat: gallery layouts, bulk category editing, and hero header improvements
2026-02-01 22:58:08 +01:00
Paul Nothaft eca36c70a2 feat: add bulk category editing for photos (#157)
Add BulkCategoryModal component that allows selecting multiple photos
and moving them to a different category in one operation.
2026-02-01 22:48:08 +01:00
Paul Nothaft 7b8d8bd92b feat: decouple hero header from gallery layouts (#158)
- Add separate header_style setting (hero/standard/minimal/none) that can
  be combined with any layout type (grid/masonry/carousel/timeline/mosaic)
- Create HeroHeader and HeroDivider components for reusable hero section
- Add hero_divider_style setting (wave/straight/angle/curve/none)
- Add database migration for header_style and hero_divider_style columns
- Remove deprecated HeroGalleryLayout component
- Fix various TypeScript errors across the codebase:
  - Add missing type properties (css_template_id, updatedAt, justified settings)
  - Fix null handling for event_date and expires_at fields
  - Fix translation function calls and i18n config
  - Remove unused imports and variables
2026-02-01 22:44:28 +01:00
Paul Nothaft 397d33a95a fix: increase upload limit to 1GB and fix category filters (#155, #156)
- Increase nginx client_max_body_size from 100MB to 1GB for video support
- Fix admin photo category filtering to properly handle numeric category IDs
  from the photo_categories table, not just legacy 'individual'/'collage' types
- Add support for 'uncategorized' filter to show photos with no category
2026-02-01 21:07:44 +01:00
Paul Nothaft 08c2e4530e Merge pull request #154 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.8.0-beta.0
2026-01-30 08:35:15 +01:00
github-actions[bot] 9ec0e2e7c0 chore(beta): release 3.8.0-beta.0 2026-01-30 07:34:38 +00:00
Paul Nothaft aacfcd517e Merge pull request #153 from the-luap/feat/new-features
feat: improve gallery layouts with aspect-ratio-aware masonry and mosaic modes (#146)
2026-01-30 08:34:24 +01:00
Paul Nothaft 27ff51e7a1 fix: use photo dimensions for mosaic aspect ratios (#146)
Thumbnails are generated as 300x300 squares, so CSS Columns alone
couldn't show varied aspect ratios. Now using the photo's width/height
metadata with CSS aspect-ratio property to force correct proportions.
2026-01-30 08:23:58 +01:00
Paul Nothaft 821d3296ea fix: use CSS Columns for gap-free mosaic layout (#146)
Replaced CSS Grid with span rules approach with CSS Columns to eliminate
gaps and white spaces in the mosaic layout. Images now flow vertically
within columns, maintaining their natural aspect ratios without gaps.
2026-01-29 23:16:14 +01:00
Paul Nothaft 46ed1bc276 feat: add quilted layout, fix mosaic, and backfill photo dimensions (#146)
- Add migration to backfill width/height for existing photos without dimensions
- Replace justified masonry mode with quilted layout (mixed sizes based on aspect ratio)
- Rewrite mosaic layout to use proper CSS Grid with span rules
- Fix theme not being applied after gallery login
- Improve columns mode distribution using shortest-column algorithm
- Apply gallery theme regardless of authentication status
2026-01-29 23:09:12 +01:00
Paul Nothaft 8711f967a1 fix: use actual photo aspect ratios in masonry columns mode (#146)
Previously, the Pinterest-style columns mode assigned random heights to
photos, causing landscape images to be cropped into portrait slots.
Now the height is calculated based on the photo's actual aspect ratio
and the column width, preserving natural proportions.
2026-01-29 21:40:41 +01:00
Paul Nothaft 5c8aed5793 Merge pull request #151 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.7.0-beta.0
2026-01-28 22:54:59 +01:00
github-actions[bot] c40f34d3de chore(beta): release 3.7.0-beta.0 2026-01-28 21:54:14 +00:00
Paul Nothaft ef2ae00ff2 Merge pull request #150 from the-luap/feat/new-features
feat: Add justified layout modes and aspect-ratio-aware mosaic (#146)
2026-01-28 22:53:58 +01:00
Paul Nothaft 608bbd50e7 feat: add justified layout modes and aspect-ratio-aware mosaic (#146)
- Add Flickr justified-layout and react-photo-album as masonry mode options
- Implement aspect-ratio-aware mosaic layout that dynamically selects
  patterns based on photo orientations to minimize cropping
- Add 9 mosaic pattern types optimized for different orientation combinations
- Add theme customizer options for masonry mode selection (columns/rows/flickr/justified)
- Add i18n translations for new layout options
2026-01-28 22:31:34 +01:00
Paul Nothaft e3024e6ffd Merge pull request #148 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.6.0-beta.0
2026-01-27 11:47:16 +01:00
github-actions[bot] b4978c0869 chore(beta): release 3.6.0-beta.0 2026-01-27 10:45:56 +00:00
Paul Nothaft cd1d50474f Merge pull request #147 from the-luap/feat/new-features
feat: add justified/rows layout mode to masonry gallery (#146) + security fixes
2026-01-27 11:45:38 +01:00
Paul Nothaft 8097a0cb53 fix: update packages to fix security vulnerabilities
- react-router-dom 6.30.2 → 6.30.3 (XSS via Open Redirects)
- react-router 6.30.2 → 6.30.3
- @remix-run/router 1.23.1 → 1.23.2
- lodash 4.17.21 → 4.17.23 (Prototype Pollution)
2026-01-27 11:40:18 +01:00
Paul Nothaft e081b56a44 feat: add justified/rows layout mode to masonry gallery (#146)
Add Google Photos-style justified row layout as a mode within masonry:

- Add masonryMode setting: 'columns' (Pinterest) or 'rows' (Google Photos)
- Create justifiedLayoutCalculator utility for row-based layouts
- Extract and store image dimensions on upload for layout calculations
- Include width/height in gallery API response
- Add row height and last row behavior controls to theme customizer
- Support responsive container width detection with ResizeObserver

Photos in rows mode maintain their aspect ratios while filling
horizontal rows at a consistent height. The number of photos per
row is automatically calculated based on target row height and
photo dimensions.

Closes #146
2026-01-27 09:58:09 +01:00
Paul Nothaft c2309af3e0 Merge pull request #144 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.5.0-beta.0
2026-01-25 15:25:57 +01:00
github-actions[bot] 32fc939c7a chore(beta): release 3.5.0-beta.0 2026-01-25 14:24:28 +00:00
Paul Nothaft 4c081601e0 Merge pull request #143 from the-luap/feat/new-features
feat: per-event custom logos, customizable event types, and multiple bug fixes
2026-01-25 15:24:14 +01:00
Paul Nothaft 85170b883f feat: add per-event custom logo upload with bug fixes
Add event-level custom logo upload/delete endpoints and UI, allowing
per-event logos to override the global branding logo in gallery views.

Also fixes several bugs discovered during testing:
- fix: category_id 'individual' parsed as NaN causing photo upload failures
- fix: gallery auth race condition where photos query fired before token stored
- fix: gallery-photos query not invalidated after favorite/like mutations
- fix: e2e test race conditions with View Gallery button detachment
2026-01-23 22:01:02 +01:00
Paul Nothaft c018604e5d Merge pull request #142 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.4.0-beta.0
2026-01-22 14:03:45 +01:00
github-actions[bot] 9c8b5e9fd6 chore(beta): release 3.4.0-beta.0 2026-01-22 13:00:33 +00:00
Paul Nothaft 151e1bf50f Merge pull request #141 from the-luap/feat/new-features
feat: new features and bug fixes for beta release
2026-01-22 14:00:14 +01:00
Paul Nothaft c5a8ffc08c fix: handle null dates in dashboard and gallery pages
Add null checks for expires_at and event_date fields to prevent
TypeError when calling parseISO() on null values. This fixes crashes
that occurred after making event dates optional.

- AdminDashboard: skip events with null expires_at in expiring filter
- GalleryPage: handle null expires_at in expiration calculation
- GalleryView: make daysUntilExpiration nullable with explicit checks
- EventDetailsPage: return null from safeParseDate for null inputs
2026-01-22 13:54:23 +01:00
Paul Nothaft d4a15dbe74 fix: remove non-functional watermark toggle from Feature Toggles
The "Enable watermark on photos" checkbox in Settings > General > Feature
Toggles was not connected to any backend logic - it stored a setting that
was never read or used. The actual working watermark functionality exists
in Settings > Branding.

This removes the dead toggle to eliminate user confusion (fixes #140).
2026-01-22 13:54:23 +01:00
Paul Nothaft 0790a1ddad feat: add per-event hero logo customization options
Add configurable hero logo settings for individual events:
- Logo visibility toggle (show/hide in hero section)
- Logo size options (small, medium, large, xlarge)
- Logo position options (top, center, bottom)

Changes include:
- Database migration for hero_logo_visible, hero_logo_size, hero_logo_position fields
- Backend routes updated to handle new settings
- Frontend admin page with logo customization controls
- HeroGalleryLayout component with dynamic logo rendering
- i18n translations for EN and DE

Also updates .gitignore to exclude test files and artifacts.
2026-01-22 13:54:23 +01:00
Paul Nothaft f8881d5bd6 feat: add customizable event types with admin management
Implements GitHub issue #139 - allows users to create and manage custom
event types beyond the default presets (wedding, birthday, corporate, other).

Backend:
- Add event_types table migration with default system types
- Create eventTypeService for CRUD operations with legacy fallback
- Add adminEventTypes routes with full REST API
- Update event validation to use dynamic event types
- Update slug generation to use custom slug_prefix

Frontend:
- Add EventTypesPage with full CRUD admin interface
- Add eventTypes.service.ts API client
- Update CreateEventPage to fetch types dynamically
- Add Event Types navigation in admin sidebar
- Add i18n translations (EN/DE)

Backward compatible: existing galleries continue to work, legacy types
accepted even if database is empty via fallback mechanisms.
2026-01-22 13:54:23 +01:00
Paul Nothaft 6b3ead747b fix: resend gallery email fails for events without password
Added optional chaining when accessing req.body.password in the
resend-email endpoint to handle cases where req.body is undefined.
This prevented the "Cannot read properties of undefined" error.

Fixes #137
2026-01-22 13:54:00 +01:00
Paul Nothaft dadef81158 fix: event-specific custom CSS settings not being saved
The ThemeCustomizerEnhanced component stored customCss in a separate
local state that was never propagated to the parent component when
hideActions was true (used in both CreateEventPage and EventDetailsPage).

Changes:
- handleChange() now includes customCss when propagating theme changes
- CSS textarea onChange now propagates customCss to parent in preview mode
- handlePresetSelect() clears customCss when selecting a preset

Fixes #136
2026-01-22 13:54:00 +01:00
Paul Nothaft 644ea22b5f Merge pull request #135 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.3.0-beta.0
2026-01-21 17:06:49 +01:00
github-actions[bot] f4da354ae7 chore(beta): release 3.3.0-beta.0 2026-01-21 16:06:24 +00:00
Paul Nothaft a59f41463f Merge pull request #134 from the-luap/feat/optional-event-date-expiration-beta
feat: add original filename preservation and Lightroom export support
2026-01-21 17:06:06 +01:00
Paul Nothaft 9872ad3aef feat: add original filename preservation and Lightroom export support
Addresses GitHub issue #132 - enables filtering client feedback and
exporting filenames for use in Lightroom.

Changes:
- Add original_filename column to photos table via migration
- Store original filename during photo upload
- Fix export service column name mismatches (path, size_bytes, uploaded_at)
- Fix table name (photo_categories instead of categories)
- Fix toFixed() calls to handle string ratings from database

Export formats available:
- TXT with comma separator (for Lightroom Library Filter)
- CSV with full metadata
- JSON for automation
- XMP sidecar files (for Lightroom/Bridge/Capture One)
2026-01-21 16:47:13 +01:00
Paul Nothaft d0880ccb03 Merge pull request #131 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.2.5-beta.0
2026-01-18 15:38:03 +01:00
github-actions[bot] 237eeea5a6 chore(beta): release 3.2.5-beta.0 2026-01-18 14:35:30 +00:00
Paul Nothaft 41bf6ff884 Merge pull request #130 from the-luap/feat/optional-event-date-expiration-beta
fix: resolve admin invitation flow issues and improve STORAGE_PATH documentation
2026-01-18 15:35:15 +01:00
Paul Nothaft 991aa98f98 fix: correct invitation activation validation and add missing translations
- Fix password minimum length validation: frontend now correctly requires
  12 characters to match backend validation (was incorrectly checking for 8)
- Fix translation key references in AcceptInvitePage to use correct paths
  (e.g., acceptInvitation.errors.* instead of acceptInvitation.*)
- Add missing translations for both EN and DE:
  - contactAdminMessage
  - passwordsMatch
  - alreadyHaveAccount
  - signIn

Fixes #129
2026-01-18 15:00:38 +01:00
Paul Nothaft 86fa1046d5 fix: correct invitation email link URL path
The invitation email was generating links to /admin/accept-invite/{token}
but the frontend route is configured at /invite/{token}. This caused
invited users to see a blank page when clicking the email link.

Fixes #129
2026-01-18 12:55:56 +01:00
Paul Nothaft 3397807670 docs: emphasize importance of STORAGE_PATH in env example 2026-01-17 15:48:24 +01:00
Paul Nothaft cdda709886 fix: add STORAGE_PATH to production docker-compose
Ensures STORAGE_PATH environment variable is explicitly set in
production deployments to prevent path resolution issues when
serving thumbnails and other storage-related operations.
2026-01-17 15:48:15 +01:00
Paul Nothaft 023bb97e66 Merge pull request #128 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.2.4-beta.0
2026-01-17 15:08:48 +01:00
github-actions[bot] cf38305f28 chore(beta): release 3.2.4-beta.0 2026-01-17 14:08:09 +00:00
Paul Nothaft 0e3674b2b0 Merge pull request #127 from the-luap/feat/optional-event-date-expiration-beta
fix: correct storage path resolution in multiple files (#96)
2026-01-17 15:07:56 +01:00
Paul Nothaft 3ccb8154eb fix: correct storage path resolution in multiple files (#96)
Fixed inconsistent storage path fallbacks that caused 500 errors when
serving thumbnails. The paths were using '../../storage' (2 levels up)
instead of '../../../storage' (3 levels up) when STORAGE_PATH env var
is not set.

Affected files:
- backend/src/routes/gallery.js
- backend/src/services/photoService.js
- backend/src/services/eventService.js
2026-01-17 14:01:29 +01:00
Paul Nothaft b5ac18121d Merge pull request #126 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.2.3-beta.0
2026-01-16 15:23:29 +01:00
github-actions[bot] b613f8fbc7 chore(beta): release 3.2.3-beta.0 2026-01-16 14:19:26 +00:00
Paul Nothaft cacaffa5c3 Merge pull request #125 from the-luap/feat/optional-event-date-expiration-beta
fix: mobile upload button not visible in gallery (#113)
2026-01-16 15:19:09 +01:00
Paul Nothaft 691e3aba09 fix: add allow_user_uploads to gallery API responses
The gallery /photos and /info endpoints were not returning the
allow_user_uploads field, causing the upload button to never show
in the frontend since the value was always undefined/false.
2026-01-16 15:15:09 +01:00
Paul Nothaft 70a0caa11f Merge pull request #124 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.2.2-beta.0
2026-01-16 14:53:44 +01:00
github-actions[bot] e808e529cd chore(beta): release 3.2.2-beta.0 2026-01-16 13:53:31 +00:00
Paul Nothaft 05a5307e22 Merge pull request #123 from the-luap/feat/optional-event-date-expiration-beta
fix: mobile upload button visibility in gallery (#113)
2026-01-16 14:53:17 +01:00
Paul Nothaft 2a2c23d116 fix: mobile upload button visibility in gallery
- Remove hidden sm:block wrapper hiding upload button on mobile for Grid layout
- Add upload button to GallerySidebar using existing allowUploads/onUploadClick props
- Ensures upload button is visible in both header and sidebar on all devices

Closes #113
2026-01-16 14:49:37 +01:00
Paul Nothaft a092d98523 Merge pull request #122 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.2.1-beta.0
2026-01-16 14:38:39 +01:00
github-actions[bot] b5f06af126 chore(beta): release 3.2.1-beta.0 2026-01-16 13:35:53 +00:00
Paul Nothaft 6cb43428d1 Merge pull request #121 from the-luap/feat/optional-event-date-expiration-beta
fix: mobile upload button visibility in gallery (#113)
2026-01-16 14:35:36 +01:00
Paul Nothaft df7dbffbff fix: mobile upload button visibility in gallery
- Remove hidden sm:block wrapper hiding upload button on mobile for Grid layout
- Add upload button to GallerySidebar using existing allowUploads/onUploadClick props
- Ensures upload button is visible in both header and sidebar on all devices

Closes #113
2026-01-16 14:29:49 +01:00
Paul Nothaft 94421a6b12 Merge pull request #120 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.2.0-beta.0
2026-01-16 09:43:51 +01:00
github-actions[bot] 7805e89bfe chore(beta): release 3.2.0-beta.0 2026-01-16 08:43:36 +00:00
Paul Nothaft 3079eaa2e5 Merge pull request #119 from the-luap/feat/optional-event-date-expiration-beta
feat: add optional event date and expiration settings
2026-01-16 09:43:23 +01:00
Paul Nothaft 2151147f2d feat: add optional event date and expiration settings
Add global settings to make event_date and expiration optional when
creating galleries. This supports non-event use cases like portraits,
corporate shoots, etc.

New features:
- Settings toggles in Settings → Event Creation tab
- "Require event date" checkbox with warning about random URL identifiers
- "Require expiration date" checkbox with warning about manual archiving
- Galleries without date use random hex suffix in slug (e.g. portrait-smith-a1b2c3)
- Galleries without expiration never expire (stay active until archived)

Backend changes:
- New migration for settings and nullable columns
- Conditional validation based on settings
- Updated slug generation with random suffix fallback
- Updated expiration checker to skip null expires_at
- Updated gallery access control for null expiration

Frontend changes:
- New checkboxes in EventsTab with warnings
- Conditional event date field (shows optional label)
- No Expiration message when expiration disabled
- Updated types for nullable event_date and expires_at

Closes #118
2026-01-16 09:39:32 +01:00
Paul Nothaft 3e69579f5a docs: add API_URL environment variable to .env.example files
Document the API_URL environment variable that is used for constructing
URLs for assets (logos, images) in email notifications. Without this
setting, the system defaults to http://localhost:3001 which causes
broken images in production emails.

Added to both root and backend .env.example files with clear
documentation about its purpose and importance.
2026-01-16 09:39:32 +01:00
Paul Nothaft 808ed1d2f1 fix: checkbox and toggle settings not persisting after page refresh
PostgreSQL's json column type returns parsed values directly (boolean
false instead of string "false"). The backend code used a truthy check
which failed for boolean false values, causing null to be returned
instead of the actual false value.

Changed condition from `if (setting.setting_value)` to explicit null
check `if (setting.setting_value !== null && setting.setting_value !== undefined)`
and added handling for already-parsed json column values.

Fixes #117
2026-01-16 09:39:32 +01:00
Paul Nothaft b40e085d28 Merge pull request #116 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.1.0-beta.0
2026-01-15 15:04:17 -05:00
github-actions[bot] d603567e21 chore(beta): release 3.1.0-beta.0 2026-01-15 20:03:57 +00:00
Paul Nothaft c6fdd38e84 Merge pull request #115 from the-luap/fix/codeql-v4-upgrade
feat: pre-generated watermarks and mobile upload button improvements
2026-01-15 15:03:25 -05:00
Paul Nothaft ae181cf92f fix: show upload button in mobile topbar instead of sidebar
The upload button was hidden in the sidebar on mobile devices, requiring
users to open the menu to find it. Now it appears directly in the topbar
for easy access on all screen sizes.

- Remove !isMobile condition from header upload button
- Add responsive text (short on mobile, full on desktop)
- Remove duplicate upload button from sidebar

Fixes #113
2026-01-15 21:00:17 +01:00
Paul Nothaft 1be974afbb feat: pre-generate watermarks for instant lightbox loading
Previously watermarks were applied on-the-fly when viewing photos in the
lightbox, causing 1+ minute load times for high-resolution images.

This change pre-generates watermarked versions during upload and when
watermark settings change, enabling instant image loading (~50-100ms).

- Add database migration for watermark_path tracking (061)
- Add watermarkGeneratorService for batch operations
- Extend watermarkService with save-to-disk capability
- Modify gallery endpoint to serve pre-generated files
- Add background regeneration when branding settings change
- Add npm script for migrating existing photos

Closes #112
2026-01-15 21:00:10 +01:00
Paul Nothaft 4c0baf242b Merge pull request #114 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.4.0
2026-01-15 14:18:51 -05:00
github-actions[bot] b12621b994 chore(main): release 2.4.0 2026-01-15 19:18:31 +00:00
Paul Nothaft 4701edc12e Merge pull request #112 from the-luap/fix/codeql-v4-upgrade
fix: dynamic website title from branding settings
2026-01-15 14:18:11 -05:00
Paul Nothaft d29aab7c70 feat: dynamic website title from branding settings
Update document title based on company name and tagline settings:
- Both filled: "{Company Name} - {Tagline}"
- Name only: "{Company Name}"
- Neither: "PicPeak - Photo Sharing Platform" (default)
2026-01-15 16:43:21 +01:00
Paul Nothaft 41f80fc898 Merge pull request #111 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.3.4
2026-01-15 10:24:59 -05:00
github-actions[bot] 0f7551ab5b chore(main): release 2.3.4 2026-01-15 15:24:23 +00:00
Paul Nothaft 7c58749806 Merge pull request #110 from the-luap/fix/codeql-v4-upgrade
fix: database migration restart bug, lightbox loading spinner, and watermark cache invalidation
2026-01-15 10:24:04 -05:00
Paul Nothaft 050ed37819 fix: add lightbox loading spinner and watermark cache invalidation
- Add spinning loader in lightbox while large images are loading
- Add onLoad callback to AuthenticatedImage for canvas and img modes
- Add ETag headers based on watermark settings for HTTP cache validation
- Add watermark version query param to photo/thumbnail URLs for cache busting
- Ensures images refresh when watermark settings are enabled/changed
2026-01-15 16:19:22 +01:00
Paul Nothaft 83a4344a01 fix: prevent database migration restart failures
- Move migrations table insert inside PostgreSQL transaction for atomicity
- Add PostgreSQL error codes 42701 (duplicate column), 42710 (duplicate
  object), and 23505 (unique violation) to error handling
- Make migrations 006 and 008 idempotent with column existence checks

Fixes #107
2026-01-15 15:43:13 +01:00
Paul Nothaft 9b50f3d6b7 Merge pull request #109 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.3.3
2026-01-15 09:24:12 -05:00
github-actions[bot] e945bc9413 chore(main): release 2.3.3 2026-01-15 14:23:34 +00:00
Paul Nothaft 3b720ed56e fix: lightbox watermark loading, white label translations, and dynamic footer year (#108)
fix: lightbox watermark loading, white label translations, and dynamic footer year
2026-01-15 09:23:10 -05:00
Paul Nothaft ce8587b24d fix: lightbox watermark loading, white label translations, and dynamic footer year
- Fix watermarked images not opening in lightbox (add /api prefix to photo URLs)
- Add i18n translations for 'White Label' and 'Hide Powered by' branding settings
- Add complete logo customization translations (EN and DE)
- Replace hardcoded © 2024 with dynamic current year in footer
- Use company name from settings in default footer text
2026-01-15 14:16:00 +01:00
Paul Nothaft fe772b52d6 Merge pull request #106 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.3.2
2026-01-15 08:03:07 -05:00
github-actions[bot] f29b77998b chore(main): release 2.3.2 2026-01-15 13:02:43 +00:00
Paul Nothaft f843e4c25c Merge pull request #105 from the-luap/fix/codeql-v4-upgrade
fix: watermark thumbnails, custom logo display, and German translations
2026-01-15 08:02:28 -05:00
Paul Nothaft ea20446a79 fix: watermark thumbnails, custom logo display, and German translations
- Fix thumbnail display when watermarks enabled globally on existing galleries
  - Backend: Apply watermarks to thumbnails at the thumbnail endpoint
  - Frontend: Remove hack that redirected thumbnails to photo endpoint
- Fix custom logo display in gallery hero sections
  - Only apply brightness/invert filter to default PicPeak logo
  - Custom logos now display as-is with drop-shadow only
- Add German translations for Event Creation and Image Protection settings
  - settings.events: Pflichtfelder, Kundenname/E-Mail erforderlich, etc.
  - settings.imageSecurity: Bildschutz, Ratenbegrenzung, Sicherheitsüberwachung
  - Protection level options in both EN and DE locales
2026-01-15 13:57:22 +01:00
Paul Nothaft 41f9b6d45d Merge pull request #104 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.3.1
2026-01-15 06:46:06 -05:00
github-actions[bot] 7b5916d3b9 chore(main): release 2.3.1 2026-01-15 11:45:24 +00:00
Paul Nothaft 657c205a4d Merge pull request #103 from the-luap/fix/codeql-v4-upgrade
fix: CI workflow fixes for protected branches
2026-01-15 06:45:06 -05:00
Paul Nothaft 1c8f686c19 Merge pull request #102 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.0.1-beta.0
2026-01-15 06:36:13 -05:00
github-actions[bot] a0f38053d3 chore(beta): release 3.0.1-beta.0 2026-01-15 11:35:58 +00:00
Paul Nothaft cb012186d9 Merge pull request #101 from the-luap/fix/codeql-v4-upgrade
fix: CI workflow fixes for protected branches
2026-01-15 06:35:46 -05:00
Paul Nothaft fe7d45dd12 fix: use Release Please extra-files instead of sync-versions job
Remove sync-versions job that fails on protected branches.
Instead, use Release Please's extra-files feature to update
package.json versions as part of the release PR.
2026-01-15 12:32:07 +01:00
Paul Nothaft c05ae5b0b9 chore: upgrade CodeQL Action from v3 to v4
Address deprecation warning - CodeQL Action v3 will be deprecated in December 2026.
2026-01-15 12:30:25 +01:00
Paul Nothaft dab012c3d1 Merge pull request #100 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.0.0-beta.0
2026-01-15 06:28:27 -05:00
github-actions[bot] 32492c5a91 chore(beta): release 3.0.0-beta.0 2026-01-15 11:24:18 +00:00
github-actions[bot] 2add85eccf chore: sync package.json versions to 2.3.0 2026-01-15 11:19:05 +00:00
Paul Nothaft 5edfb44776 Merge pull request #99 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.3.0
2026-01-15 06:18:39 -05:00
github-actions[bot] eedb0fe49c chore(main): release 2.3.0 2026-01-15 11:18:00 +00:00
Paul Nothaft 3c7dc2013f feat: beta/stable release channels with update notifications and bug fixes (#98)
feat: beta/stable release channels with update notifications and bug fixes
2026-01-15 06:17:41 -05:00
Paul Nothaft 617e778a48 feat: implement beta/stable release channels with update notifications
Add dual-channel release strategy for stable and beta releases:

Release Channels:
- Stable channel: production-ready releases (stable, latest, v2.3.0)
- Beta channel: early access features (beta, v2.3.0-beta.1)
- Configurable via PICPEAK_CHANNEL environment variable

Update Notifications:
- Admin dashboard shows available updates for configured channel
- Checks GitHub Releases API with 1-hour cache
- Can be disabled with UPDATE_CHECK_ENABLED=false

CI/CD Changes:
- New release-please-beta.yml workflow for beta prereleases
- Docker build workflow produces stable/beta tags based on branch
- Beta versions use v2.3.0-beta.1 format

New Files:
- .github/workflows/release-please-beta.yml
- release-please-config-beta.json
- .release-please-manifest-beta.json
- backend/src/services/updateCheckService.js
- frontend/src/components/admin/UpdateNotification.tsx

Modified Files:
- docker-compose.production.yml (channel selection)
- .env.example (PICPEAK_CHANNEL, UPDATE_CHECK_ENABLED)
- backend/src/routes/adminSystem.js (/updates endpoint)
- frontend components (VersionInfo, AdminDashboard)
- i18n locales (en.json, de.json)
- README.md and DEPLOYMENT_GUIDE.md (documentation)
2026-01-15 12:11:06 +01:00
Paul Nothaft e3c3c4c951 fix: gallery thumbnails not loading (404 errors) #96
The gallery thumbnail endpoint was returning 404 when thumbnail_path
was null or the file didn't exist, unlike the admin endpoint which
generates thumbnails on demand using ensureThumbnail().

- Import ensureThumbnail from imageProcessor
- Use ensureThumbnail() in gallery thumbnail route to generate
  thumbnails on demand if they don't exist
- This matches the admin endpoint behavior

Fixes #96
2026-01-15 11:22:13 +01:00
Paul Nothaft 0e3b50d1b6 fix: watermark upload JSON parsing and image quality preservation
- Fix JSON parsing error when uploading watermark logo by handling both
  JSON-stringified and raw string paths
- Ensure publicPath is JSON.stringify'd consistently when saving
- Preserve original image format (PNG/WebP/JPEG) when applying watermarks
- Use maximum quality (100) to prevent unnecessary recompression
2026-01-12 13:24:21 +01:00
Paul Nothaft bd8b885f7f fix: display new password after admin password reset
- show-admin-credentials.js --reset now displays the generated password
  instead of just saying "[NEWLY RESET - stored in database]"
- Also sets must_change_password flag to force password change on login
- Updated DEPLOYMENT_GUIDE.md and SIMPLE_SETUP.md to clarify that the
  new password is displayed in console output after reset
2026-01-12 13:23:24 +01:00
Paul Nothaft 3cdc0ea715 fix: prevent unnecessary image recompression and fix SQLite migration #95
- Skip image processing for basic/standard protection levels when no
  fingerprinting or watermarking is enabled
- Preserve original image format (PNG/WebP/JPEG) instead of always
  converting to JPEG
- Fix SQLite migration failure for fresh installations by adding
  multilingual columns to email_templates table before inserting
  admin email templates

Fixes #95
2026-01-12 13:20:39 +01:00
github-actions[bot] a2ff9eae3f chore: sync package.json versions to 2.2.4 2026-01-08 22:24:23 +00:00
276 changed files with 31926 additions and 5784 deletions
+16
View File
@@ -22,6 +22,7 @@ REDIS_PASSWORD=your_secure_redis_password_here
# Admin Account (initial setup)
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@yourdomain.com
ADMIN_PASSWORD=your_secure_admin_password_here
# Email Configuration
# For Gmail: use app-specific password
@@ -39,6 +40,11 @@ EMAIL_FROM=noreply@yourdomain.com
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com
# API URL for email assets (logos, images in notification emails)
# This must be the publicly accessible URL where email recipients can load images.
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
API_URL=https://yourdomain.com/api
# Frontend API base
# For pre-built images and production behind a reverse proxy, keep '/api'.
# If you rebuild the frontend yourself, you may set a full URL at build time.
@@ -50,6 +56,16 @@ VITE_API_URL=/api
# DB_PORT=5432
# REDIS_PORT=6379
# Release Channel
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
# 'stable' uses the :stable tag (same as :latest on main)
# 'beta' uses the :beta tag for pre-release versions
PICPEAK_CHANNEL=stable
# Update Check Configuration
# Set to 'false' to disable update notifications in admin UI
UPDATE_CHECK_ENABLED=true
# Timezone
TZ=UTC
@@ -9,7 +9,7 @@ assignees: ''
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
Instead, please email security@example.com with the details.
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
For minor security improvements or questions, you can use this template:
+37 -9
View File
@@ -8,10 +8,10 @@ name: Build and Push Docker Images
on:
push:
branches: [ main, develop ]
tags: [ 'v*.*.*' ] # Triggered by Release Please tags
branches: [ main, beta ]
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
pull_request:
branches: [ main ]
branches: [ main, beta ]
release:
types: [ published ] # Triggered when Release Please creates a release
workflow_dispatch:
@@ -42,6 +42,18 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine build context
id: context
run: |
# Determine if this is a beta or stable release
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Determine build platforms
id: platforms
run: |
@@ -88,10 +100,12 @@ jobs:
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Build and push Backend Docker image
uses: docker/build-push-action@v5
@@ -123,7 +137,7 @@ jobs:
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-backend.sarif'
category: 'backend-vulnerabilities'
@@ -139,6 +153,18 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine build context
id: context
run: |
# Determine if this is a beta or stable release
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Determine build platforms
id: platforms
run: |
@@ -185,10 +211,12 @@ jobs:
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Build and push Frontend Docker image
uses: docker/build-push-action@v5
@@ -220,7 +248,7 @@ jobs:
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v3
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-frontend.sarif'
category: 'frontend-vulnerabilities'
+37
View File
@@ -0,0 +1,37 @@
name: Release Please (Beta)
on:
push:
branches: [beta]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- name: Run Release Please
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config-beta.json
manifest-file: .release-please-manifest-beta.json
target-branch: beta
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
run: |
echo "## Beta Release Created!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ steps.release.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
-35
View File
@@ -34,38 +34,3 @@ jobs:
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
# Sync version to package.json files after release
sync-versions:
needs: release-please
if: ${{ needs.release-please.outputs.release_created }}
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: main
- name: Update package.json versions
run: |
VERSION="${{ needs.release-please.outputs.version }}"
echo "Updating package.json files to version $VERSION"
# Update backend package.json
cd backend
npm version $VERSION --no-git-tag-version --allow-same-version
cd ..
# Update frontend package.json
cd frontend
npm version $VERSION --no-git-tag-version --allow-same-version
cd ..
- name: Commit version updates
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add backend/package.json frontend/package.json
git diff --staged --quiet || git commit -m "chore: sync package.json versions to ${{ needs.release-please.outputs.version }}"
git push
+21
View File
@@ -86,6 +86,14 @@ docs/*_PLAN.md
docs/test-*.md
docs/feature-*.md
# Scaffolding documentation (local development reference)
docs/DATABASE_SCHEMA.md
docs/BACKEND_SERVICES.md
docs/API_ROUTES.md
docs/FRONTEND_ARCHITECTURE.md
docs/DEVELOPER_ONBOARDING.md
docs/ENVIRONMENT_VARIABLES.md
# Local backup directory (from testing)
backup/
@@ -94,3 +102,16 @@ backup/
# Local SQLite files in backend
backend/*.sqlite*
backend/*.db
# Test files and artifacts
test-images/
test-logo*.jpg
test-logo*.png
test-results/
# Development docker compose
docker-compose.dev.yml
# New layout development files
new-layouts/
+3
View File
@@ -0,0 +1,3 @@
{
".": "3.23.0-beta.0"
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.2.4"
".": "2.6.1"
}
+737
View File
@@ -5,6 +5,743 @@ All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [3.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)
### Bug Fixes
* 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))
## [3.17.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.16.0-beta.0...v3.17.0-beta.0) (2026-03-05)
### Features
* configurable upload batch size for reverse proxy compatibility ([9b7495e](https://github.com/the-luap/picpeak/commit/9b7495e0054975e66c9b5006c24a9fae63969de4))
## [3.16.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.3-beta.0...v3.16.0-beta.0) (2026-03-05)
### Features
* 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))
## [3.15.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.2-beta.0...v3.15.3-beta.0) (2026-03-02)
### Bug Fixes
* 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))
* 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))
* **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))
## [2.6.0](https://github.com/the-luap/picpeak/compare/v2.5.1...v2.6.0) (2026-03-11)
### Features
* 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))
* configurable upload batch size for reverse proxy compatibility ([4243363](https://github.com/the-luap/picpeak/commit/424336340bef8e1629490ade154f0ceebb2a71e1))
### Bug Fixes
* 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))
## [2.5.1](https://github.com/the-luap/picpeak/compare/v2.5.0...v2.5.1) (2026-02-22)
### Bug Fixes
* 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) ([33483cf](https://github.com/the-luap/picpeak/commit/33483cf32dfae57f8da51c0765353792239135f9))
## [2.5.0](https://github.com/the-luap/picpeak/compare/v2.4.0...v2.5.0) (2026-02-21)
### Features
* add admin dark mode and SEO/robots.txt settings ([9c2a0d2](https://github.com/the-luap/picpeak/commit/9c2a0d272a21dfcace2ec795034e2f1adcba47e0))
* add bulk category editing for photos ([#157](https://github.com/the-luap/picpeak/issues/157)) ([eca36c7](https://github.com/the-luap/picpeak/commit/eca36c70a23f18f937a9f5bddeff855e18f364c3))
* add category hero/cover photo selection ([#163](https://github.com/the-luap/picpeak/issues/163)) ([6c30e2c](https://github.com/the-luap/picpeak/commit/6c30e2c2edd19a24d4f30a9558690bb7e2331b32))
* add customizable event types with admin management ([f8881d5](https://github.com/the-luap/picpeak/commit/f8881d5bd62d449fb40917ec8c20f0eb16c1fdad))
* add Gallery Premium and Gallery Story layouts (Beta) ([e179def](https://github.com/the-luap/picpeak/commit/e179def3cceefe5fd6acd5574f2986e4f9e223ef))
* add hero image focal point picker with anchor positioning ([#162](https://github.com/the-luap/picpeak/issues/162)) ([734868a](https://github.com/the-luap/picpeak/commit/734868abc23731b0ac9ad73e799194df1e6aa6ab))
* add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([608bbd5](https://github.com/the-luap/picpeak/commit/608bbd50e7b31d49c7516a00e96f284fa16e2777))
* Add justified layout modes and aspect-ratio-aware mosaic ([#146](https://github.com/the-luap/picpeak/issues/146)) ([ef2ae00](https://github.com/the-luap/picpeak/commit/ef2ae00ff20b754c2f2ed797e18c146d12d7f31a))
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) ([e081b56](https://github.com/the-luap/picpeak/commit/e081b56a44bf9fdaa3dd225d5dd4dde35bfe83d3))
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) + security fixes ([cd1d504](https://github.com/the-luap/picpeak/commit/cd1d50474f673b759c2f9401fdbe209a84773e39))
* add optional event date and expiration settings ([3079eaa](https://github.com/the-luap/picpeak/commit/3079eaa2e5d1728c2c0f315626cc253e4b08edc2))
* add optional event date and expiration settings ([2151147](https://github.com/the-luap/picpeak/commit/2151147f2d3134448ff32130da44678e2942d73c)), closes [#118](https://github.com/the-luap/picpeak/issues/118)
* add original filename preservation and Lightroom export support ([a59f414](https://github.com/the-luap/picpeak/commit/a59f41463f960a3a74ce3933dc7db84ee3a2018d))
* add original filename preservation and Lightroom export support ([9872ad3](https://github.com/the-luap/picpeak/commit/9872ad3aef6488b359c5499a6dc3d8bfbfa48fde))
* add per-event custom logo upload with bug fixes ([85170b8](https://github.com/the-luap/picpeak/commit/85170b883f504d83f1d862abb3f4e46741074826))
* add per-event hero logo customization options ([0790a1d](https://github.com/the-luap/picpeak/commit/0790a1ddad774af89827a0a392e9fae0a945bff2))
* add 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 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)
* decouple hero header from gallery layouts ([#158](https://github.com/the-luap/picpeak/issues/158)) ([7b8d8bd](https://github.com/the-luap/picpeak/commit/7b8d8bd92ba7a96717bb4d821b38dddc395f701a))
* 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))
* 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))
* 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))
* per-event custom logos, customizable event types, and multiple bug fixes ([4c08160](https://github.com/the-luap/picpeak/commit/4c081601e02888d7ad289acb7847aee9d6f5703f))
* 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))
* show original filename in admin UI ([#184](https://github.com/the-luap/picpeak/issues/184)) ([0891be1](https://github.com/the-luap/picpeak/commit/0891be197fdb7d92ade5a293b8db0bed26fa6e3a))
## [3.15.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.1-beta.0...v3.15.2-beta.0) (2026-02-22)
### Bug Fixes
* add allow_user_uploads to gallery API responses ([691e3ab](https://github.com/the-luap/picpeak/commit/691e3aba09f2148afe902a0bb0139d062634e669))
* add STORAGE_PATH to production docker-compose ([cdda709](https://github.com/the-luap/picpeak/commit/cdda70988664a177b351abc6a259ec39664d17ff))
* 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)
* 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))
* 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))
* 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))
* handle null dates in dashboard and gallery pages ([c5a8ffc](https://github.com/the-luap/picpeak/commit/c5a8ffc08cd4c53c37fe4fb9cde8519a68f1f343))
* hero header state and preview in admin theme editor ([#158](https://github.com/the-luap/picpeak/issues/158)) ([f554f46](https://github.com/the-luap/picpeak/commit/f554f463b3492346dba067c0980b52ef42dd5e70))
* improve ghost button visibility in admin dark mode ([4912e2b](https://github.com/the-luap/picpeak/commit/4912e2bccf282134d5598a8ac80942ed46d0523c))
* improve password validation errors and event list UX ([#170](https://github.com/the-luap/picpeak/issues/170), [#171](https://github.com/the-luap/picpeak/issues/171)) ([171abb3](https://github.com/the-luap/picpeak/commit/171abb31615484d77cf95a99cb5634afa0160adc))
* improve photo serving, category filters, and upload chunking ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156), [#161](https://github.com/the-luap/picpeak/issues/161)) ([fa4c838](https://github.com/the-luap/picpeak/commit/fa4c83812d87cfa63394e51186e320a072929d37))
* increase upload limit to 1GB and fix category filters ([#155](https://github.com/the-luap/picpeak/issues/155), [#156](https://github.com/the-luap/picpeak/issues/156)) ([397d33a](https://github.com/the-luap/picpeak/commit/397d33a95a09e0b0986c3f6cf5965c544992a764))
* 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))
* remove non-functional watermark toggle from Feature Toggles ([d4a15db](https://github.com/the-luap/picpeak/commit/d4a15dbe74d0d70bbe6ff03362dc7337fb8f4c5c))
* render minimal/none header styles, cap hero height, switch category hero images ([#158](https://github.com/the-luap/picpeak/issues/158), [#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([bc6c48b](https://github.com/the-luap/picpeak/commit/bc6c48bb2429505c2de3641693a8ff4f623a4951))
* resend gallery email fails for events without password ([6b3ead7](https://github.com/the-luap/picpeak/commit/6b3ead747b1395d8ea2b3d135a5ac24db05e2eb8)), closes [#137](https://github.com/the-luap/picpeak/issues/137)
* resolve admin invitation flow issues and improve STORAGE_PATH documentation ([41bf6ff](https://github.com/the-luap/picpeak/commit/41bf6ff884d5ef3181f95f3aa4a528434c23947a))
* resolve code quality issues and add missing i18n keys ([#162](https://github.com/the-luap/picpeak/issues/162), [#163](https://github.com/the-luap/picpeak/issues/163)) ([329d224](https://github.com/the-luap/picpeak/commit/329d224846d3f4eefa31e42337f34047c267d578))
* resolve 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))
* 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))
* show upload button in mobile topbar instead of sidebar ([ae181cf](https://github.com/the-luap/picpeak/commit/ae181cf92fc9c1e85cad7a7b843a4d83cec636ac)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
* sync 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 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))
* 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))
### Documentation
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
* emphasize importance of STORAGE_PATH in env example ([3397807](https://github.com/the-luap/picpeak/commit/3397807670784e02cbe34a7a60db43c95d64f19c))
* 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) ([cd00bc1](https://github.com/the-luap/picpeak/commit/cd00bc13d4e02a86a0f1742ed1f11f064614b8da))
## [3.15.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.15.0-beta.0...v3.15.1-beta.0) (2026-02-21)
### Bug Fixes
* 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))
* 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))
## [3.15.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.14.0-beta.0...v3.15.0-beta.0) (2026-02-17)
### Features
* original filename in admin UI, update dialog, security hardening, and bug fixes ([bcf2745](https://github.com/the-luap/picpeak/commit/bcf2745ab64acb968ae4bd0710b28e78c14f340c))
### Bug Fixes
* events without expiration date incorrectly shown as expired ([c4f16eb](https://github.com/the-luap/picpeak/commit/c4f16eb76c909158abdb63aa4cc22f817f274dc5))
## [3.14.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.13.1-beta.0...v3.14.0-beta.0) (2026-02-17)
### Features
* 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)
* original filename in admin UI, update dialog, and security hardening ([3ea9d5b](https://github.com/the-luap/picpeak/commit/3ea9d5b1219980032cbee7a2564c0004948923f5))
* show original filename in admin UI ([#184](https://github.com/the-luap/picpeak/issues/184)) ([0891be1](https://github.com/the-luap/picpeak/commit/0891be197fdb7d92ade5a293b8db0bed26fa6e3a))
## [3.13.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.13.0-beta.0...v3.13.1-beta.0) (2026-02-15)
### Bug Fixes
* 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))
## [3.13.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.12.0-beta.0...v3.13.0-beta.0) (2026-02-06)
### Features
* 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))
## [3.12.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.11.0-beta.0...v3.12.0-beta.0) (2026-02-06)
### Features
* add admin dark mode and SEO/robots.txt settings ([9c2a0d2](https://github.com/the-luap/picpeak/commit/9c2a0d272a21dfcace2ec795034e2f1adcba47e0))
### Bug Fixes
* improve ghost button visibility in admin dark mode ([4912e2b](https://github.com/the-luap/picpeak/commit/4912e2bccf282134d5598a8ac80942ed46d0523c))
* 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))
## [3.11.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.10.1-beta.0...v3.11.0-beta.0) (2026-02-06)
### Features
* add Gallery Premium and Gallery Story layouts (Beta) ([e179def](https://github.com/the-luap/picpeak/commit/e179def3cceefe5fd6acd5574f2986e4f9e223ef))
* 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))
### Bug Fixes
* 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))
* 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))
## [3.10.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.10.0-beta.0...v3.10.1-beta.0) (2026-02-03)
### Bug Fixes
* 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))
## [3.10.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.9.0-beta.0...v3.10.0-beta.0) (2026-02-03)
### Features
* 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 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))
* gallery layouts, hero customization, bulk categories & event types ([d9e00dc](https://github.com/the-luap/picpeak/commit/d9e00dc0dbd7cef0ddb4665e5306c98aac3573e3))
### Bug Fixes
* 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 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))
* 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))
## [3.9.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.8.0-beta.0...v3.9.0-beta.0) (2026-02-01)
### Features
* add bulk category editing for photos ([#157](https://github.com/the-luap/picpeak/issues/157)) ([eca36c7](https://github.com/the-luap/picpeak/commit/eca36c70a23f18f937a9f5bddeff855e18f364c3))
* decouple hero header from gallery layouts ([#158](https://github.com/the-luap/picpeak/issues/158)) ([7b8d8bd](https://github.com/the-luap/picpeak/commit/7b8d8bd92ba7a96717bb4d821b38dddc395f701a))
* gallery layouts, bulk category editing, and hero header improvements ([7037106](https://github.com/the-luap/picpeak/commit/7037106bff62593bba600d898a781f79f07b459d))
### Bug Fixes
* 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))
## [3.8.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.7.0-beta.0...v3.8.0-beta.0) (2026-01-30)
### Features
* 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))
* 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))
### Bug Fixes
* 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))
## [3.7.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.6.0-beta.0...v3.7.0-beta.0) (2026-01-28)
### Features
* 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))
## [3.6.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.5.0-beta.0...v3.6.0-beta.0) (2026-01-27)
### Features
* 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))
### Bug Fixes
* update packages to fix security vulnerabilities ([8097a0c](https://github.com/the-luap/picpeak/commit/8097a0cb530bd8003597cde81606231efadb0bf5))
## [3.5.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.4.0-beta.0...v3.5.0-beta.0) (2026-01-25)
### Features
* add per-event custom logo upload with bug fixes ([85170b8](https://github.com/the-luap/picpeak/commit/85170b883f504d83f1d862abb3f4e46741074826))
* per-event custom logos, customizable event types, and multiple bug fixes ([4c08160](https://github.com/the-luap/picpeak/commit/4c081601e02888d7ad289acb7847aee9d6f5703f))
## [3.4.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.3.0-beta.0...v3.4.0-beta.0) (2026-01-22)
### Features
* add customizable event types with admin management ([f8881d5](https://github.com/the-luap/picpeak/commit/f8881d5bd62d449fb40917ec8c20f0eb16c1fdad))
* add per-event hero logo customization options ([0790a1d](https://github.com/the-luap/picpeak/commit/0790a1ddad774af89827a0a392e9fae0a945bff2))
* new features and bug fixes for beta release ([151e1bf](https://github.com/the-luap/picpeak/commit/151e1bf50f206ae0571fa044c75b8bc9f0f40120))
### Bug Fixes
* 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)
* handle null dates in dashboard and gallery pages ([c5a8ffc](https://github.com/the-luap/picpeak/commit/c5a8ffc08cd4c53c37fe4fb9cde8519a68f1f343))
* remove non-functional watermark toggle from Feature Toggles ([d4a15db](https://github.com/the-luap/picpeak/commit/d4a15dbe74d0d70bbe6ff03362dc7337fb8f4c5c))
* 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)
## [3.3.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.5-beta.0...v3.3.0-beta.0) (2026-01-21)
### Features
* 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))
## [3.2.5-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.4-beta.0...v3.2.5-beta.0) (2026-01-18)
### Bug Fixes
* add STORAGE_PATH to production docker-compose ([cdda709](https://github.com/the-luap/picpeak/commit/cdda70988664a177b351abc6a259ec39664d17ff))
* 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)
* resolve admin invitation flow issues and improve STORAGE_PATH documentation ([41bf6ff](https://github.com/the-luap/picpeak/commit/41bf6ff884d5ef3181f95f3aa4a528434c23947a))
### Documentation
* emphasize importance of STORAGE_PATH in env example ([3397807](https://github.com/the-luap/picpeak/commit/3397807670784e02cbe34a7a60db43c95d64f19c))
## [3.2.4-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.3-beta.0...v3.2.4-beta.0) (2026-01-17)
### Bug Fixes
* 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))
## [3.2.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.2-beta.0...v3.2.3-beta.0) (2026-01-16)
### Bug Fixes
* add allow_user_uploads to gallery API responses ([691e3ab](https://github.com/the-luap/picpeak/commit/691e3aba09f2148afe902a0bb0139d062634e669))
* 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))
## [3.2.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.1-beta.0...v3.2.2-beta.0) (2026-01-16)
### Bug Fixes
* 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 ([#113](https://github.com/the-luap/picpeak/issues/113)) ([05a5307](https://github.com/the-luap/picpeak/commit/05a5307e22dc45be4b75b2996ff9fac65dec399d))
## [3.2.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.0-beta.0...v3.2.1-beta.0) (2026-01-16)
### Bug Fixes
* 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)) ([6cb4342](https://github.com/the-luap/picpeak/commit/6cb43428d1e703267edeacda9ede050a8c4f8e0c))
## [3.2.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.1.0-beta.0...v3.2.0-beta.0) (2026-01-16)
### Features
* 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)
### Bug Fixes
* 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)
### Documentation
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
## [3.1.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.0.1-beta.0...v3.1.0-beta.0) (2026-01-15)
### Features
* dynamic website title from branding settings ([d29aab7](https://github.com/the-luap/picpeak/commit/d29aab7c70c5777451666fb7d5c7a9729dab684a))
* 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))
### Bug Fixes
* add lightbox loading spinner and watermark cache invalidation ([050ed37](https://github.com/the-luap/picpeak/commit/050ed378199eb3b15c7c7f243792f68f858803f5))
* lightbox watermark loading, white label translations, and dynamic footer year ([ce8587b](https://github.com/the-luap/picpeak/commit/ce8587b24df3f53a11a74348eff8b5c5b96c5488))
* prevent database migration restart failures ([83a4344](https://github.com/the-luap/picpeak/commit/83a4344a01de4f65c5024fdf2d177a04457ccd2f)), closes [#107](https://github.com/the-luap/picpeak/issues/107)
* 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)
* watermark thumbnails, custom logo display, and German translations ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6))
## [3.0.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.0.0-beta.0...v3.0.1-beta.0) (2026-01-15)
### Bug Fixes
* CI workflow fixes for protected branches ([cb01218](https://github.com/the-luap/picpeak/commit/cb012186d93403a1ac4e2d2f5283319603b290d6))
* 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))
## [2.3.2](https://github.com/the-luap/picpeak/compare/v2.3.1...v2.3.2) (2026-01-15)
### Bug Fixes
* 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))
## [2.3.1](https://github.com/the-luap/picpeak/compare/v2.3.0...v2.3.1) (2026-01-15)
### Bug Fixes
* CI workflow fixes for protected branches ([657c205](https://github.com/the-luap/picpeak/commit/657c205a4d8ca49070b69973f4c7a3d1418633af))
* use Release Please extra-files instead of sync-versions job ([fe7d45d](https://github.com/the-luap/picpeak/commit/fe7d45dd122b2dca1b2a21ba5c86d32b9a193074))
## [3.0.0-beta.0](https://github.com/the-luap/picpeak/compare/v2.3.0-beta.0...v3.0.0-beta.0) (2026-01-15)
### ⚠ BREAKING CHANGES
* Deployment now requires external reverse proxy for SSL/HTTPS
### Features
* add Apple Liquid Glass templates, image security settings, and automated releases ([6033461](https://github.com/the-luap/picpeak/commit/6033461be118ce78277ec568e1ef1ceeff7311c8))
* add complete translation support for backup admin page ([e9f92e6](https://github.com/the-luap/picpeak/commit/e9f92e66d08ac7001c31a3ee8f43ee8306bc79a9))
* Add CSS template system with custom gallery styling support ([0da45e6](https://github.com/the-luap/picpeak/commit/0da45e699ad998031aa56a92f2da5ee61a04e285))
* add event management, gallery customization, and release automationFeature/event rename ([40ee671](https://github.com/the-luap/picpeak/commit/40ee67171d41522037bf9d4e7675b62ec564346d))
* add feedback management enhancements ([0064122](https://github.com/the-luap/picpeak/commit/0064122eff12029300ab7f95078b5710c3c2d08c))
* add GitHub Actions workflow for Docker image builds ([4029559](https://github.com/the-luap/picpeak/commit/40295599547b86af7fea3359c7486918d2cd0236))
* add multi-administrator support with RBAC and fix backup/restore for S3 ([892e47d](https://github.com/the-luap/picpeak/commit/892e47d017064d7922536f8e138bbb290a45cdc9))
* **admin:** external media import modal + thumbnail fixes for reference events\n\n- Photos tab: replace inline external folder picker with a modal opened via "Import from External Folder" button next to "Upload Photos"; add info that all pictures in the selected folder will be imported.\n- Admin thumbnails: align list endpoint to /api/admin/photos/:eventId/photos and always return thumbnail_url to trigger on-demand generation; normalize external paths to avoid duplicated folder segments (e.g., individual/individual) that broke resolver; improve thumbnail logging.\n- Use authenticated image fetching on admin feedback pages to prevent 401s in automation.\n- i18n: add backup.external.warning strings; complete German backup/restore coverage; add common keys (notSet, of, up, select, selected).\n- Docs: add Local (npm) setup for EXTERNAL_MEDIA_ROOT in deployment guide.\n\nRefs [#17](https://github.com/the-luap/picpeak/issues/17) gallery feature request: https://github.com/the-luap/picpeak/issues/17 ([49c7778](https://github.com/the-luap/picpeak/commit/49c77785e7a776890f15c0c541dcd18b74a86c6e))
* **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))
* completely rewrite GitHub mirror to create new history from target commit ([febacb7](https://github.com/the-luap/picpeak/commit/febacb79ad86d35a222ec86a1e7da65747bbe19a))
* consolidate setup scripts and guides into unified solution ([29a8ff9](https://github.com/the-luap/picpeak/commit/29a8ff914cf838918ab827280e4415afbce5ca8d))
* **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))
* enhance mirror-to-github workflow with commit-based history filtering ([b4b09c1](https://github.com/the-luap/picpeak/commit/b4b09c16504ca64ce265c7bd0bf0c901dbbd0638))
* **events:** add CSS template selector to event edit page ([6a6c2cd](https://github.com/the-luap/picpeak/commit/6a6c2cd34db26a53b5fb96415650e8136a74e47f))
* exclude Claude contributor from GitHub mirror workflow ([abbcdb1](https://github.com/the-luap/picpeak/commit/abbcdb11136afd8cf4eb21c2103e81d22b9c886f))
* fix analytics dashboard and implement complete Umami integration ([45ce988](https://github.com/the-luap/picpeak/commit/45ce98806d4c87ddce8c400d07cc667bde435d75))
* **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))
* implement comprehensive backup and restore system with S3 support ([f6a79c8](https://github.com/the-luap/picpeak/commit/f6a79c815e3085a56cbe7bac2964dd135f5e88bb))
* implement feedback filter for liked/favorited photos (Issue [#17](https://github.com/the-luap/picpeak/issues/17)) ([41857ec](https://github.com/the-luap/picpeak/commit/41857ec499e2aab4347173cb031db246b9a032f6))
* implement gallery feedback system with version tracking for backups ([dc1419c](https://github.com/the-luap/picpeak/commit/dc1419c051dae44532bfc2b2c2bc00942577dc22))
* implement gallery logo customization (Issue [#17](https://github.com/the-luap/picpeak/issues/17)) ([909e760](https://github.com/the-luap/picpeak/commit/909e760447c76bb35dbffa553a4665edc5ebccd9))
* **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))
* **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))
* overhaul public landing page and backup tooling ([2a4d388](https://github.com/the-luap/picpeak/commit/2a4d38813f7ab64a6bbb3a666f3c98a29443488d))
* **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))
* support per-gallery password toggle ([5d6c061](https://github.com/the-luap/picpeak/commit/5d6c061f1c4fd20581b1e74fa114c96530b5de53))
* update GitHub mirror workflow to start history from specific commit ([08da01f](https://github.com/the-luap/picpeak/commit/08da01f021788a1b81a3a3aabf120636c4e1a90a))
### Bug Fixes
* add missing route for feedback management page ([517128f](https://github.com/the-luap/picpeak/commit/517128fd99863ea203e39268ffa6c1ff093bcbd0))
* add missing translations and fix BackupHistory useTranslation error ([99e4778](https://github.com/the-luap/picpeak/commit/99e47785e4a53c7ef9f95421413a2704b15b456d))
* 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))
* **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))
* auto-convert old date formats to new date-fns syntax ([e1aca6b](https://github.com/the-luap/picpeak/commit/e1aca6b00c5affb914a0db44a6264c8e54fdffd6))
* **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))
* **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))
* complete backup page translations and improve UI ([7387a5e](https://github.com/the-luap/picpeak/commit/7387a5e9f90965a6cfb75589b2338bf28263b840))
* complete restore page translations and fix structure ([618e269](https://github.com/the-luap/picpeak/commit/618e2695fdf844cc0ae961b50a9b6eb99bc46a03))
* configure github-release plugin to use GitHub API instead of Gitea ([2624ea6](https://github.com/the-luap/picpeak/commit/2624ea6130a38224597f0c4d3f3d0341c334472f))
* correct GitHub repository path in Drone CI release config ([247e154](https://github.com/the-luap/picpeak/commit/247e154afefd3aef285e459bb7fc39ea460e53e2))
* correct import statements for api in backup JSX files ([30f6780](https://github.com/the-luap/picpeak/commit/30f678048417aeffe6eabefc7bed5e4dc2267f25))
* correct malformed gallery URLs in admin panel View Gallery links ([3074748](https://github.com/the-luap/picpeak/commit/3074748bbc6a8cb8fc0e95d2f24d626f0d0444d0))
* correct password generator function name in reset password route ([65d796b](https://github.com/the-luap/picpeak/commit/65d796b9f09417f85bb3209c5e5fbe597a4bb2d3))
* correct script name in Gitea mirror workflow ([828d6bc](https://github.com/the-luap/picpeak/commit/828d6bc456175007b72998db7116eec993750435))
* **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))
* critical database connection pool exhaustion issues ([8588133](https://github.com/the-luap/picpeak/commit/8588133a4e35774e46f7c605638758e5b2a4a9e2))
* **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 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))
* force github-release plugin to use GitHub API instead of Gitea ([558a966](https://github.com/the-luap/picpeak/commit/558a966f8509ac7b77c732f8cc5855c9f88a4bab))
* **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 auth errors and JSON parsing in admin panel ([b2ae5f1](https://github.com/the-luap/picpeak/commit/b2ae5f18ad4622ea9cb0b5b593dad19e5d14cf60))
* handle legacy non-JSON logo paths when replacing logo ([0d5ce48](https://github.com/the-luap/picpeak/commit/0d5ce48dccf0c61f210725ffae15dafc5e9f7cab))
* harden gallery downloads and per-gallery auth ([fc1bf53](https://github.com/the-luap/picpeak/commit/fc1bf534129092ca3638e4a4bc47274cd297fa5f))
* implement 9 production enhancements and security fixes ([c584369](https://github.com/the-luap/picpeak/commit/c584369d5d5c33fd794cf82a2aea8089bd10e514))
* improve admin credentials display and configuration ([ad495a9](https://github.com/the-luap/picpeak/commit/ad495a92c46d02849ce0d9176cff43c83c5c4b57))
* improve version bump workflow with better conflict resolution ([c787510](https://github.com/the-luap/picpeak/commit/c7875102c5196a9ef3038c2d5e0ee313fbb2782a))
* JSON serialize favicon and logo URLs for PostgreSQL storage ([b83f427](https://github.com/the-luap/picpeak/commit/b83f4272b584f937fea1f47656182e514b12d980))
* 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))
* multiple improvements and CI/CD updates ([bf70567](https://github.com/the-luap/picpeak/commit/bf705674d505b0cb1b82fecc74aa8d95edd50a47))
* **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))
* 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 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 description field from migration 035 app_settings inserts ([22cc406](https://github.com/the-luap/picpeak/commit/22cc40617f88e1f0a636fc049c78601fc1f38c33))
* remove file requirement from GitHub release in Drone CI ([8335916](https://github.com/the-luap/picpeak/commit/833591681adf29d99a1dfa7c43d5aee7a6cb98ba))
* remove formatBoolean calls from migration 032 - critical production fix ([0502ed3](https://github.com/the-luap/picpeak/commit/0502ed34c9fe76acacc2aecd02151564d109cf0b))
* remove unnecessary publish-manifest job from Docker workflow ([986b101](https://github.com/the-luap/picpeak/commit/986b101040674f2253fcdfda99a9e603535daaa0))
* remove unused formatBoolean import from migration 033 ([1238db5](https://github.com/the-luap/picpeak/commit/1238db58c25e97513c9bdcb5dcc26b1034e9f074))
* remove updated_at field from password reset query ([ed0243e](https://github.com/the-luap/picpeak/commit/ed0243ec398acca26490ef27cbe3cfe5fa9b95a6))
* remove updated_at from app_settings inserts in multiple migrations ([4c42b4c](https://github.com/the-luap/picpeak/commit/4c42b4c60157755b770bea3b78d42fe6abd60afa))
* replace github-release plugin with direct curl API call ([76a466c](https://github.com/the-luap/picpeak/commit/76a466c0776eeabe3eac6a480bd699c2ae5c60bc))
* resolve backend startup errors in development ([f8fb1c3](https://github.com/the-luap/picpeak/commit/f8fb1c3f4b2b5de53182e987a9dfe042704320b9))
* 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 CI/CD version bump race condition ([0bf4764](https://github.com/the-luap/picpeak/commit/0bf4764a0720f6f199442a738a885a2edaae2a4d))
* resolve database connection error for analytics settings ([95939d5](https://github.com/the-luap/picpeak/commit/95939d57e6857646d261b0f049bdda752602caeb))
* resolve date formatting error in event creation ([c51d756](https://github.com/the-luap/picpeak/commit/c51d7565035146cc3f689c0cc4b508b78d9bb5ee))
* resolve development environment issues ([61299a3](https://github.com/the-luap/picpeak/commit/61299a33c4f92730fe8b14f6035f61325d952b94))
* resolve duplicate logger declaration and syntax error in rate limit service ([0fe6d73](https://github.com/the-luap/picpeak/commit/0fe6d738b222555b27cbf8a36f455b1c15c4f4e8))
* resolve feedback validation issues from GitHub issue [#16](https://github.com/the-luap/picpeak/issues/16) ([f26beca](https://github.com/the-luap/picpeak/commit/f26becad1dfa72c62b6ecec491be025644426d67))
* resolve feedback validation issues from GitHub issue [#16](https://github.com/the-luap/picpeak/issues/16) ([67ff415](https://github.com/the-luap/picpeak/commit/67ff4158404347bc7c13dee5b4e13260eb0e743d))
* resolve GitHub issues [#4](https://github.com/the-luap/picpeak/issues/4), [#8](https://github.com/the-luap/picpeak/issues/8), [#9](https://github.com/the-luap/picpeak/issues/9), and [#10](https://github.com/the-luap/picpeak/issues/10) ([934d6dd](https://github.com/the-luap/picpeak/commit/934d6ddc5847f65db6371a4043b764f6d4cd6c8b))
* resolve GitHub mirror workflow cherry-pick failure with merge commits ([d6adde4](https://github.com/the-luap/picpeak/commit/d6adde4e093537aeecf8b190513a1171c3ecc82c))
* resolve language-specific column issues in core migrations ([62617f6](https://github.com/the-luap/picpeak/commit/62617f627f56aedd132fa20528b1d7c7e272c85c))
* resolve migration conflicts and duplicate numbering ([a401fbd](https://github.com/the-luap/picpeak/commit/a401fbdc54f30c18b5aa2440d7b6887ca12e00eb))
* resolve multiple feedback management issues ([ad75818](https://github.com/the-luap/picpeak/commit/ad758185666bf4ac52965f16d1c0e1e052887ac2))
* resolve multiple issues from GitHub issue [#14](https://github.com/the-luap/picpeak/issues/14) ([e91209f](https://github.com/the-luap/picpeak/commit/e91209f7cb38a5b840e74ed6acd8d490ef9d2294))
* resolve port configuration issues and database column mismatch ([6de64a1](https://github.com/the-luap/picpeak/commit/6de64a1df18932badd7bb1b9928d09e9477f0c3f))
* resolve PostgreSQL migration issues for development environment ([ee855a3](https://github.com/the-luap/picpeak/commit/ee855a3502ecd1a5556e378e9995de86e3548de1))
* resolve production UI and API issues ([d5790ad](https://github.com/the-luap/picpeak/commit/d5790ad635596842926a358753932e5c422590d6))
* resolve SIGPIPE error in GitHub mirror workflow file cleanup ([b7c8953](https://github.com/the-luap/picpeak/commit/b7c8953cb4d4a2541dcb38865c8a7beef0edf494))
* resolve translation interpolation issue for download button ([c1e10f1](https://github.com/the-luap/picpeak/commit/c1e10f14a30797c76169c2531de5d04976ee4888))
* **security:** upgrade Alpine base image to fix libpng and c-ares CVEs ([b706eeb](https://github.com/the-luap/picpeak/commit/b706eeb5d332e9618706193976a7241aee53d879))
* **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))
* simplify Drone github-release step to avoid shell parsing issues ([94f10e1](https://github.com/the-luap/picpeak/commit/94f10e164502e6848cd720ee5a5c2822abbde46f))
* stabilize uploads and guest feedback filters ([aaaf598](https://github.com/the-luap/picpeak/commit/aaaf59817b3978635d2282c006853e183ab944d4))
* update all deployment guide links in README.md ([6389b9d](https://github.com/the-luap/picpeak/commit/6389b9df3f616c09a9bbbf1a2988764b0c3aeb77))
* update deployment guide with critical URL configuration and nginx port fixes ([1cadce1](https://github.com/the-luap/picpeak/commit/1cadce196bb04a0575d83437618454d4ca5bcdac))
* update form-data and multer to address security vulnerabilities ([7750170](https://github.com/the-luap/picpeak/commit/7750170832dddf81a33c7c2409b37b0b7bc1f290))
* update Gitea mirror workflow to selectively remove scripts ([296430e](https://github.com/the-luap/picpeak/commit/296430e4d7e01a6be031dbb89dd25f563b163a97))
* update GitHub mirror action to support fine-grained personal access tokens ([827eb48](https://github.com/the-luap/picpeak/commit/827eb4819b7da6171d48613963d176399cad80c6))
* use admin API for Umami config in analytics page ([a54a2c0](https://github.com/the-luap/picpeak/commit/a54a2c0fdaa28193d1359da73bc7fb61476e2a58))
* use plugins/gitea-release for Drone CI/CD ([0c783c6](https://github.com/the-luap/picpeak/commit/0c783c66d0dfe8cb637f0db7349ae9637d7bf787))
* use plugins/github-release for Drone CI/CD ([f926cd3](https://github.com/the-luap/picpeak/commit/f926cd3adf513858bc7b291582c7ca2efdf93ff8))
* watermark upload JSON parsing and image quality preservation ([0e3b50d](https://github.com/the-luap/picpeak/commit/0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a))
### Documentation
* add minimum system requirements section to README ([4615a5d](https://github.com/the-luap/picpeak/commit/4615a5d795b415367edf4882628377936b29ab32))
* add PUID/PGID note for Docker bind mounts to avoid permission issues ([0178e71](https://github.com/the-luap/picpeak/commit/0178e71c67f198c6013ece52b0a2da0e2f1a6b2a))
* add transparency note about AI-assisted development ([35e360d](https://github.com/the-luap/picpeak/commit/35e360dcf7ac68833bec81f2e79f4a11a76a0e87))
* add warnings about $ character in Docker Compose passwords ([87d1761](https://github.com/the-luap/picpeak/commit/87d1761091bb97747821aa810a58f3978a59d08f))
* clarify VITE_API_URL usage; remove FRONTEND_API_URL; add storage vars; simplify compose mounts and external DB example (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([758c085](https://github.com/the-luap/picpeak/commit/758c085467e579e9f6b16df2298747fdddf2b205))
* **compose:** fix backend healthcheck path; remove frontend VITE_API_URL env and document /api proxy (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([ecbc488](https://github.com/the-luap/picpeak/commit/ecbc48815ded99a052ef057e69427c823cd34ece))
* fix deployment/admin routing and CORS guidance; add AGENTS.md; ignore AGENTS.md (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([dad1787](https://github.com/the-luap/picpeak/commit/dad1787aad8763637373e8eb87a47728d3d568cc))
* follow-up on PR [#15](https://github.com/the-luap/picpeak/issues/15) — clarify VITE_API_URL usage, compose mounts, and admin routing (refs [#15](https://github.com/the-luap/picpeak/issues/15)) ([e9171c7](https://github.com/the-luap/picpeak/commit/e9171c71159cb41b91a099621bd2d7a7985dd239))
* **readme:** reflect new External Media reference mode and update roadmap (gallery feedback status) ([ee13556](https://github.com/the-luap/picpeak/commit/ee13556c5cb4f24fe88e14fd00b821acf65b11cb))
* replace email addresses with GitHub issue links ([0c989ce](https://github.com/the-luap/picpeak/commit/0c989ce08699ce68b131a9cc4ba4f14e06e3d221))
* update deployment guide with GitHub Container Registry images ([2c9a56f](https://github.com/the-luap/picpeak/commit/2c9a56f217218f0700817d150b3de115e9503baa))
### Code Refactoring
* simplify deployment structure with direct port exposure ([6492cb9](https://github.com/the-luap/picpeak/commit/6492cb9ec8f8b811297aa71c153b9fe6a00e947a))
## [2.3.0](https://github.com/the-luap/picpeak/compare/v2.2.4...v2.3.0) (2026-01-15)
### Features
* 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))
* implement beta/stable release channels with update notifications ([617e778](https://github.com/the-luap/picpeak/commit/617e778a48e0f0c24fcb8441d00ed2a816f19c03))
### Bug Fixes
* display new password after admin password reset ([bd8b885](https://github.com/the-luap/picpeak/commit/bd8b885f7f060160eb852870d143f25ce628f3db))
* gallery thumbnails not loading (404 errors) [#96](https://github.com/the-luap/picpeak/issues/96) ([e3c3c4c](https://github.com/the-luap/picpeak/commit/e3c3c4c951c52de99bd0afd95b08d119153997b4))
* 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))
* watermark upload JSON parsing and image quality preservation ([0e3b50d](https://github.com/the-luap/picpeak/commit/0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a))
## [2.2.4](https://github.com/the-luap/picpeak/compare/v2.2.3...v2.2.4) (2026-01-08)
+116 -148
View File
@@ -2,9 +2,24 @@
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
## 🎯 Quick Start - Simple Setup (Recommended for Beginners)
## 📋 Table of Contents
For the easiest installation without Docker or complex configurations, use our **unified setup script**:
- [Quick Start](#-quick-start)
- [Prerequisites](#prerequisites)
- [Configuration](#-configuration)
- [Deployment](#-deployment)
- [First Login](#-first-login)
- [Release Channels](#-release-channels)
- [Reverse Proxy Setup](#-reverse-proxy-setup)
- [External Media Library](#external-media-library)
- [Maintenance](#-maintenance)
- [Troubleshooting](#-troubleshooting)
## 🚀 Quick Start
### Option 1: Automated Setup Script (Easiest)
For the simplest installation, use our unified setup script:
```bash
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
@@ -12,27 +27,11 @@ chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
This automated script handles everything including:
- Choice between Docker or Native installation
- OS detection and dependency installation
- Database setup and service configuration
- SSL/HTTPS setup (optional)
Perfect for:
- Small to medium deployments
- Local or VPS installations
- Users new to server management
- Quick testing and evaluation
This script handles Docker/Native installation choice, OS detection, dependencies, database setup, and optional SSL.
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
---
## 🐳 Docker Compose Deployment
### Option 1: Using Pre-built Images (Recommended)
PicPeak provides official Docker images via GitHub Container Registry for quick deployment without building:
### Option 2: Docker with Pre-built Images (Recommended)
```bash
# Clone repository for configuration files
@@ -43,35 +42,40 @@ cd picpeak
cp .env.example .env
nano .env # Edit with your values
# Use pre-built images deployment
# Create required directories
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
# Deploy using pre-built images
docker compose -f docker-compose.production.yml up -d
# Check logs
docker compose -f docker-compose.production.yml logs -f
```
The production compose file uses:
- **Backend**: `ghcr.io/the-luap/picpeak/backend:latest`
- **Frontend**: `ghcr.io/the-luap/picpeak/frontend:latest`
**Available image tags:**
| Channel | Tags | Description |
|---------|------|-------------|
| Stable | `stable`, `latest`, `v2.3.0` | Production-ready releases |
| Beta | `beta`, `v2.3.0-beta.1` | Early access to new features |
| Branch | `main`, `beta` | Latest from each branch |
Available tags:
- `latest` - Latest stable release
- `main` - Latest main branch build
- `develop` - Development branch (may be unstable)
- `v1.0.0` - Specific version tags
To select a channel, set `PICPEAK_CHANNEL` in your `.env` file (see [Release Channels](#release-channels) section)
### Option 2: Building from Source
### Option 3: Build from Source
If you need to customize the application or the pre-built images aren't available, you can build locally:
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
cp .env.example .env
nano .env # Edit with your values
## 📋 Table of Contents
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
- [Prerequisites](#prerequisites)
- [Quick Start](#quick-start)
- [Configuration](#configuration)
- [Deployment](#deployment)
- [First Login](#first-login)
- [Reverse Proxy Setup](#reverse-proxy-setup)
- [Maintenance](#maintenance)
- [Troubleshooting](#troubleshooting)
- [External Media Library](#external-media-library)
docker compose build
docker compose up -d
```
## Prerequisites
@@ -80,106 +84,6 @@ If you need to customize the application or the pre-built images aren't availabl
- SMTP server credentials for emails
- At least 2GB RAM and 20GB storage
## 🚀 Quick Start
### Method 1: Using Pre-built Images (Fastest)
1. **Clone the repository for configs**
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
```
2. **Set up environment**
```bash
cp .env.example .env
nano .env # Edit with your values
```
3. **Create required directories**
```bash
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
```
4. **Deploy using pre-built images**
```bash
docker compose -f docker-compose.production.yml up -d
```
5. **Check logs**
```bash
docker compose -f docker-compose.production.yml logs -f
```
## External Media Library
PicPeak can reference an existing, readonly media library mounted into the backend container. This avoids copying originals into PicPeak storage.
- Map your host library path to the container as readonly in `docker-compose.production.yml`:
- Add volume under `backend`: `- ${EXTERNAL_MEDIA}:/external-media:ro`
- Add backend env: `EXTERNAL_MEDIA_ROOT=/external-media`
- In `.env`, set:
- `EXTERNAL_MEDIA=/mnt/photos` (example host path)
- `EXTERNAL_MEDIA_ROOT=/external-media`
Usage:
- In Admin → Events, set “Source Mode” to “Reference (external folder)”, select a folder under `/external-media`, then import to index and generate thumbnails. Originals stay in your library.
Backups and Archives:
- Backups only include data under `STORAGE_PATH` and exclude external originals. The backup manifest includes `metadata.external_references = { excluded: true, events: N, photos: M }` and the Admin UI surfaces a warning.
- Archiving reference events creates a manifestonly ZIP and deletes thumbnails for that event. External originals are never moved or deleted.
Local (npm) setup (no Docker):
1. Create or choose a folder that contains your external originals, e.g. `/Users/you/Pictures/picpeak-external` (macOS/Linux) or `C:\\Pictures\\picpeak-external` (Windows).
2. In `backend/.env` (or your shell), set:
- `EXTERNAL_MEDIA_ROOT=/absolute/path/to/picpeak-external`
- Ensure `STORAGE_PATH` points to your PicPeak storage (defaults to `./storage`).
3. Start services from source:
- Backend: `cd backend && npm install && npm run migrate && JWT_SECRET=... npm start`
- Frontend: `cd frontend && npm install && npm run dev` (or build + serve)
4. In Admin → Events:
- Create an event, set “Source Mode” to “Reference (external folder)”.
- Use the folder picker to browse under your `EXTERNAL_MEDIA_ROOT` and select the subfolder to reference.
- Click “Import from selected folder” to index files and generate thumbnails on demand.
Notes:
- PicPeak only reads from `EXTERNAL_MEDIA_ROOT`; it never modifies or deletes your originals there.
- Thumbnails are generated under `STORAGE_PATH/thumbnails` and are included in backups; originals in `EXTERNAL_MEDIA_ROOT` are excluded.
- On Windows, use absolute paths (e.g., `C:\\Photos\\Library`) for `EXTERNAL_MEDIA_ROOT`.
### Method 2: Building from Source
1. **Clone the repository**
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
```
2. **Set up environment**
```bash
cp .env.example .env
nano .env # Edit with your values
```
3. **Create required directories**
```bash
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
```
4. **Build and deploy**
```bash
docker compose build
docker compose up -d
```
5. **Check logs**
```bash
docker compose logs -f
```
## 🔧 Configuration
### Essential Environment Variables
@@ -358,14 +262,16 @@ docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt
# Show current admin username and email (password is hidden)
docker exec picpeak-backend node scripts/show-admin-credentials.js
# Reset the admin password to a new random password
# Reset the admin password to a new random password (displays new password in console)
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
```
> **Note:** When using `--reset`, the new password will be displayed in the console output. Save it immediately - it will not be shown again!
#### Important Security Notes
- **Login requires the email address**, not username
- The admin password is only displayed once during initial setup
- When resetting password, the new password is displayed once in the console - save it immediately
- **Password change is MANDATORY** on first login - the system will force you to change it
- If you lose the password before first login, use the `--reset` option to generate a new one
- New password requirements: minimum 12 characters, mixed case, numbers, and special characters
@@ -446,6 +352,61 @@ ADMIN_EMAIL=your-email@yourdomain.com
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
## 🔄 Release Channels
PicPeak offers two release channels for different needs:
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
### Configuring Your Channel
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
```bash
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
The `docker-compose.production.yml` uses this variable for both backend and frontend images:
```yaml
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
```
### Switching Channels
To switch between channels:
```bash
# Edit your .env file
nano .env
# Change PICPEAK_CHANNEL=stable to PICPEAK_CHANNEL=beta (or vice versa)
# Pull the new images and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
### Update Notifications
The admin dashboard automatically notifies you when updates are available for your channel. This feature:
- Checks GitHub releases hourly (cached to avoid rate limits)
- Shows updates relevant to your current channel (stable or beta)
- Can be disabled by setting `UPDATE_CHECK_ENABLED=false` in your `.env`
## 🔒 Reverse Proxy Setup
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
@@ -656,20 +617,27 @@ docker compose up -d
docker compose ps
```
#### Specific Version Updates
#### Specific Version or Channel Updates
To use a specific version of the images:
To use a specific version or switch channels, update your `.env` file:
```bash
# Edit docker-compose.production.yml to specify version tags
# Change: ghcr.io/the-luap/picpeak/backend:latest
# To: ghcr.io/the-luap/picpeak/backend:v1.0.0
# Edit .env to change the channel or pin to a specific version
nano .env
# Options for PICPEAK_CHANNEL:
# - stable (recommended, production-ready)
# - beta (early access to new features)
# - v2.3.0 (pin to specific stable version)
# - v2.3.0-beta.1 (pin to specific beta version)
# Then pull and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
The admin dashboard will notify you when updates are available for your configured channel.
### Database Migrations
Migrations run automatically on startup, but you can run them manually:
+64 -3
View File
@@ -7,12 +7,27 @@
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/)
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/)
[Homepage](https://www.picpeak.app) · [Live Demo](https://demo.picpeak.app) · [Documentation](DEPLOYMENT_GUIDE.md)
</div>
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
![PicPeak Gallery Preview](docs/screenshot-gallery.png)
## 🎮 Live Demo
Try PicPeak without installing anything:
| | |
|---|---|
| **Demo URL** | [demo.picpeak.app](https://demo.picpeak.app) |
| **Admin Panel** | [demo.picpeak.app/admin](https://demo.picpeak.app/admin) |
| **Email** | `demo@picpeak.app` |
| **Password** | `Demo2026!` |
> The demo resets periodically. Uploaded content may be removed without notice.
## 🌟 Why Choose PicPeak?
Unlike expensive SaaS solutions, PicPeak gives you:
@@ -68,9 +83,9 @@ cp .env.example .env
nano .env
# Start with Docker Compose
docker-compose up -d
docker compose up -d
# Access at http://localhost:3005
# Access at http://localhost:3000
```
Note on Docker file permissions (PUID/PGID)
@@ -79,7 +94,51 @@ Note on Docker file permissions (PUID/PGID)
- Example in `.env`:
- `PUID=1000`
- `PGID=1000`
- Without this, creating events, uploads, thumbnails, or logs can fail with Permission denied.
- Without this, creating events, uploads, thumbnails, or logs can fail with "Permission denied".
## 🔄 Release Channels
PicPeak offers two release channels for different needs:
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
### Switching Channels
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
```bash
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
Then update your containers:
```bash
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
### Update Notifications
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
```bash
UPDATE_CHECK_ENABLED=false
```
## 📖 Documentation
@@ -283,6 +342,8 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
<p align="center">
Made with ❤️ by photographers, for photographers
<br>
<a href="https://www.picpeak.app">Homepage</a> •
<a href="https://demo.picpeak.app">Live Demo</a> •
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
<a href="DEPLOYMENT_GUIDE.md">Documentation</a> •
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
+6 -6
View File
@@ -6,8 +6,8 @@ We release patches for security vulnerabilities. Currently supported versions:
| Version | Supported |
| ------- | ------------------ |
| 1.x.x | :white_check_mark: |
| < 1.0 | :x: |
| 2.x.x | :white_check_mark: |
| < 2.0 | :x: |
## Reporting a Vulnerability
@@ -15,9 +15,9 @@ We take the security of PicPeak seriously. If you have discovered a security vul
### 1. **Do NOT create a public GitHub issue**
### 2. Report the vulnerability by:
- Opening a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
- Mark it clearly as "SECURITY" in the title
### 2. Report the vulnerability privately by:
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- **Alternative:** Email us at **info@picpeak.app** with the details
- Include:
- Description of the vulnerability
- Steps to reproduce
@@ -82,7 +82,7 @@ We believe in responsible disclosure. Once a vulnerability is fixed:
## Contact
- Security issues: [Create a security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/the-luap/picpeak/issues)
Thank you for helping keep PicPeak and its users safe!
+2
View File
@@ -451,6 +451,8 @@ cd /opt/picpeak/app/backend
sudo -u picpeak node scripts/reset-admin-password.js
```
> **Note:** The new password will be displayed in the console output and saved to `ADMIN_PASSWORD_RESET.txt`. Save it immediately!
### Getting Help
1. **Check logs:**
+6
View File
@@ -14,6 +14,11 @@ ADMIN_URL=https://photos.example.com
FRONTEND_URL=https://photos.example.com
BACKEND_URL=https://photos.example.com # Or https://api.photos.example.com if separate
# API URL for email assets (logos, images in emails)
# This must be the publicly accessible URL where recipients can load images
# If not set, defaults to http://localhost:3001 which will break images in production emails
API_URL=https://photos.example.com/api
# Database Configuration
DATABASE_CLIENT=pg
DB_HOST=localhost
@@ -39,6 +44,7 @@ SMTP_PASS=your-sendgrid-api-key
EMAIL_FROM=noreply@example.com
# Storage Paths
# IMPORTANT: STORAGE_PATH must be set to avoid file path resolution issues
# Docker deployment:
STORAGE_PATH=/app/storage
EVENTS_PATH=/app/storage/events
+5 -9
View File
@@ -1,4 +1,4 @@
FROM node:20-alpine AS builder
FROM node:22-alpine AS builder
# Add build arguments
ARG CACHEBUST=1
@@ -11,10 +11,6 @@ LABEL org.opencontainers.image.source="https://github.com/the-luap/picpeak"
LABEL org.opencontainers.image.description="PicPeak Backend Service"
LABEL org.opencontainers.image.licenses="MIT"
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
# Pin to npm 10.x which supports --omit=dev flag
RUN npm install -g npm@10
WORKDIR /app
# Copy package files
@@ -27,15 +23,15 @@ 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 (BusyBox CVEs)
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN apk upgrade --no-cache
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
# Pin to npm 10.x which supports --omit=dev flag
# 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
+2 -1
View File
@@ -1,6 +1,6 @@
# Database Migrations
This directory contains database migrations for the Wedding Photo Sharing platform.
This directory contains database migrations for the PicPeak photo sharing platform.
## Directory Structure
@@ -9,6 +9,7 @@ Essential migrations that are always run for new deployments. These include:
- `init.js` - Initial database schema creation
- Backup service tables (029-035)
- Gallery feedback tables (033)
- Pre-generated watermarks (061)
### `/legacy`
Migrations needed only when upgrading from older versions. New deployments can skip these as the core schema already includes all necessary tables and columns.
+2 -2
View File
@@ -14,8 +14,8 @@ exports.up = async function(knex) {
// Create default admin user if none exists
const adminExists = await knex('admin_users').first();
if (!adminExists) {
// Generate a secure random password
const generatedPassword = generateReadablePassword();
// Use ADMIN_PASSWORD from environment if set, otherwise generate a random one
const generatedPassword = process.env.ADMIN_PASSWORD || generateReadablePassword();
const passwordHash = await bcrypt.hash(generatedPassword, 12); // Increased rounds for better security
// Get admin credentials from environment or use defaults
@@ -3,6 +3,81 @@
* These templates support the RBAC (Role-Based Access Control) feature
*/
exports.up = async function(knex) {
// First, ensure the email_templates table has multilingual columns
// This is needed for fresh installations where legacy migrations don't run
const columnInfo = await knex('email_templates').columnInfo();
if (!columnInfo.subject_en) {
// Need to add multilingual columns
console.log('Adding multilingual columns to email_templates table...');
// Check if we're using SQLite or PostgreSQL
const client = knex.client.config.client;
const isSqlite = client === 'sqlite3' || client === 'better-sqlite3';
if (isSqlite) {
// SQLite doesn't support column rename directly in all versions
// We need to recreate the table with new structure
// Get existing data
const existingData = await knex('email_templates').select('*');
// Drop the old table
await knex.schema.dropTable('email_templates');
// Create new table with multilingual columns
await knex.schema.createTable('email_templates', (table) => {
table.increments('id').primary();
table.string('template_key').unique().notNullable();
table.string('subject_en');
table.string('subject_de');
table.text('body_html_en');
table.text('body_html_de');
table.text('body_text_en');
table.text('body_text_de');
table.json('variables');
table.datetime('updated_at').defaultTo(knex.fn.now());
});
// Re-insert existing data with column mapping
for (const row of existingData) {
await knex('email_templates').insert({
template_key: row.template_key,
subject_en: row.subject,
subject_de: row.subject, // Copy to German as default
body_html_en: row.body_html,
body_html_de: row.body_html,
body_text_en: row.body_text,
body_text_de: row.body_text,
variables: row.variables,
updated_at: row.updated_at
});
}
console.log('Migrated email_templates table to multilingual structure');
} else {
// PostgreSQL supports ALTER TABLE for column operations
await knex.schema.alterTable('email_templates', (table) => {
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
});
await knex.schema.alterTable('email_templates', (table) => {
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Copy English values to German as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
}
}
// Check which templates already exist
const existingTemplates = await knex('email_templates')
.select('template_key')
@@ -0,0 +1,107 @@
/**
* Migration: Add event_types table
*
* Creates a customizable event types system to replace hardcoded event types.
* This allows users to:
* - Rename existing event types (wedding, birthday, corporate, other)
* - Create custom event types with custom slug prefixes
* - Associate default theme presets with event types
*
* Backward compatible: Existing events keep their event_type strings.
* New events can use either legacy strings or custom event type slug_prefix.
*/
const { createTableIfNotExists, insertIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Creating event_types table...');
// Create event_types table
const hasEventTypesTable = await knex.schema.hasTable('event_types');
if (!hasEventTypesTable) {
await knex.schema.createTable('event_types', (table) => {
table.increments('id').primary();
table.string('name', 100).notNullable(); // Display name: "Family Shoot"
table.string('slug_prefix', 50).unique().notNullable(); // URL prefix: "family"
table.string('emoji', 10); // Icon emoji: "👨‍👩‍👧"
table.string('theme_preset', 50); // Default theme: "elegantWedding"
table.text('theme_config'); // Custom theme JSON overrides (optional)
table.integer('display_order').defaultTo(0); // Sorting in dropdowns
table.boolean('is_system').defaultTo(false); // Protect default types
table.boolean('is_active').defaultTo(true); // Allow hiding types
table.timestamp('created_at').defaultTo(knex.fn.now());
table.timestamp('updated_at').defaultTo(knex.fn.now());
// Indexes for performance
table.index('slug_prefix');
table.index('display_order');
table.index('is_active');
});
console.log('event_types table created');
} else {
console.log('event_types table already exists, skipping creation');
}
// Seed default event types (matching current hardcoded values)
const existingTypes = await knex('event_types').select('slug_prefix');
const existingSlugs = existingTypes.map(t => t.slug_prefix);
const defaultTypes = [
{
name: 'Wedding',
slug_prefix: 'wedding',
emoji: '💒',
theme_preset: 'elegantWedding',
display_order: 1,
is_system: true,
is_active: true
},
{
name: 'Birthday',
slug_prefix: 'birthday',
emoji: '🎂',
theme_preset: 'birthdayFun',
display_order: 2,
is_system: true,
is_active: true
},
{
name: 'Corporate',
slug_prefix: 'corporate',
emoji: '🏢',
theme_preset: 'corporateTimeline',
display_order: 3,
is_system: true,
is_active: true
},
{
name: 'Other',
slug_prefix: 'other',
emoji: '📸',
theme_preset: 'default',
display_order: 4,
is_system: true,
is_active: true
}
];
const typesToInsert = defaultTypes.filter(type => !existingSlugs.includes(type.slug_prefix));
if (typesToInsert.length > 0) {
await knex('event_types').insert(typesToInsert);
console.log(`Inserted ${typesToInsert.length} default event types`);
} else {
console.log('Default event types already exist, skipping seed');
}
console.log('Migration 061_add_event_types_table completed successfully');
};
exports.down = async function(knex) {
console.log('Rolling back event_types table...');
// Drop the table (data will be lost)
await knex.schema.dropTableIfExists('event_types');
console.log('event_types table dropped');
};
@@ -0,0 +1,53 @@
/**
* Migration: Add optional event date and expiration settings
* These settings control whether event_date and expiration are required
* when creating new events, supporting non-event use cases like portraits.
*/
exports.up = async function(knex) {
// Add new settings for optional date and expiration
const settings = [
{ setting_key: 'event_require_event_date', setting_value: JSON.stringify(true), setting_type: 'boolean' },
{ setting_key: 'event_require_expiration', setting_value: JSON.stringify(true), setting_type: 'boolean' }
];
for (const setting of settings) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({
...setting,
updated_at: knex.fn.now()
});
}
}
// Make event_date and expires_at columns nullable
// PostgreSQL supports ALTER COLUMN ... DROP NOT NULL
// SQLite requires table recreation (handled differently)
const client = knex.client.config.client;
if (client === 'pg' || client === 'postgresql') {
// PostgreSQL: directly alter columns
await knex.raw('ALTER TABLE events ALTER COLUMN event_date DROP NOT NULL');
await knex.raw('ALTER TABLE events ALTER COLUMN expires_at DROP NOT NULL');
} else if (client === 'sqlite3' || client === 'better-sqlite3') {
// SQLite: columns are already effectively nullable in most cases
// SQLite doesn't enforce NOT NULL as strictly, and altering requires table recreation
// For safety, we'll skip the schema change for SQLite as it's complex
// The application logic will handle null values appropriately
console.log('SQLite detected - skipping schema alteration (columns will accept NULL values)');
}
};
exports.down = async function(knex) {
// Remove the settings
await knex('app_settings')
.whereIn('setting_key', [
'event_require_event_date',
'event_require_expiration'
])
.del();
// Note: We don't restore NOT NULL constraints as that could fail
// if there are existing NULL values in the database
};
@@ -0,0 +1,28 @@
/**
* Migration 061: Add pre-generated watermark path to photos table
* - photos.watermark_path: path to pre-generated watermarked image
* - photos.watermark_generated_at: timestamp of watermark generation
*/
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 061_add_watermark_path');
// photos.watermark_path (nullable - path to pre-generated watermarked image)
await addColumnIfNotExists(knex, 'photos', 'watermark_path', (table) => {
table.string('watermark_path', 512);
});
// photos.watermark_generated_at (nullable - when watermark was last generated)
await addColumnIfNotExists(knex, 'photos', 'watermark_generated_at', (table) => {
table.timestamp('watermark_generated_at');
});
console.log('Migration 061_add_watermark_path completed');
};
exports.down = async function(knex) {
console.log('Rollback: 061_add_watermark_path');
// Keep columns (safe rollback not removing data). Intentionally no-op.
};
@@ -0,0 +1,70 @@
/**
* Migration: Add hero logo customization settings to events table
*
* Allows per-event customization of the hero gallery logo:
* - hero_logo_visible: Show/hide the logo overlay
* - hero_logo_size: Logo size (small, medium, large, xlarge)
* - hero_logo_position: Logo position (top, center, bottom)
*
* Addresses GitHub Issue #138: Add Option to customize the Hero gallery layout
*/
exports.up = async function (knex) {
console.log('Adding hero logo settings to events table...');
// Add hero_logo_visible column
const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible');
if (!hasVisibleColumn) {
await knex.schema.table('events', (table) => {
table.boolean('hero_logo_visible').notNullable().defaultTo(true);
});
console.log('Added hero_logo_visible column');
}
// Add hero_logo_size column
const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size');
if (!hasSizeColumn) {
await knex.schema.table('events', (table) => {
table.string('hero_logo_size', 20).notNullable().defaultTo('medium');
});
console.log('Added hero_logo_size column');
}
// Add hero_logo_position column
const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position');
if (!hasPositionColumn) {
await knex.schema.table('events', (table) => {
table.string('hero_logo_position', 20).notNullable().defaultTo('top');
});
console.log('Added hero_logo_position column');
}
console.log('Migration 062_add_hero_logo_settings completed successfully');
};
exports.down = async function (knex) {
console.log('Rolling back hero logo settings...');
const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible');
if (hasVisibleColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('hero_logo_visible');
});
}
const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size');
if (hasSizeColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('hero_logo_size');
});
}
const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position');
if (hasPositionColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('hero_logo_position');
});
}
console.log('Hero logo settings columns dropped');
};
@@ -0,0 +1,23 @@
/**
* Migration 062: Add original_filename to photos table
* - photos.original_filename: preserves the original filename from upload
* This enables Lightroom integration by exporting filtered filenames
*/
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 062_add_original_filename');
// photos.original_filename (nullable - original filename before renaming)
await addColumnIfNotExists(knex, 'photos', 'original_filename', (table) => {
table.string('original_filename', 512);
});
console.log('Migration 062_add_original_filename completed');
};
exports.down = async function(knex) {
console.log('Rollback: 062_add_original_filename');
// Keep column (safe rollback not removing data). Intentionally no-op.
};
@@ -0,0 +1,53 @@
/**
* Migration: Add custom logo support to events table
*
* Allows per-event custom logo that overrides the global branding logo:
* - hero_logo_url: Public path to the uploaded custom logo
* - hero_logo_path: Full filesystem path to the custom logo
*
* Logo priority: Event custom logo > Global branding logo > Default PicPeak logo
*
* Addresses GitHub Issue #138: Per-event custom logo option
*/
exports.up = async function (knex) {
console.log('Adding custom logo columns to events table...');
const hasUrlColumn = await knex.schema.hasColumn('events', 'hero_logo_url');
if (!hasUrlColumn) {
await knex.schema.table('events', (table) => {
table.string('hero_logo_url', 500).nullable().defaultTo(null);
});
console.log('Added hero_logo_url column');
}
const hasPathColumn = await knex.schema.hasColumn('events', 'hero_logo_path');
if (!hasPathColumn) {
await knex.schema.table('events', (table) => {
table.string('hero_logo_path', 500).nullable().defaultTo(null);
});
console.log('Added hero_logo_path column');
}
console.log('Migration 063_add_event_custom_logo completed successfully');
};
exports.down = async function (knex) {
console.log('Rolling back custom logo columns...');
const hasUrlColumn = await knex.schema.hasColumn('events', 'hero_logo_url');
if (hasUrlColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('hero_logo_url');
});
}
const hasPathColumn = await knex.schema.hasColumn('events', 'hero_logo_path');
if (hasPathColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('hero_logo_path');
});
}
console.log('Custom logo columns dropped');
};
@@ -0,0 +1,105 @@
/**
* Migration: Backfill photo dimensions
*
* This migration extracts width/height from existing photos that don't have
* these dimensions stored. This is needed for aspect-ratio-aware layouts
* (masonry, mosaic, justified) to work properly.
*/
const path = require('path');
const fs = require('fs');
exports.up = async function(knex) {
// Check if the width/height columns exist
const hasWidth = await knex.schema.hasColumn('photos', 'width');
const hasHeight = await knex.schema.hasColumn('photos', 'height');
if (!hasWidth || !hasHeight) {
console.log('[Migration 064] Width/height columns not found, skipping backfill');
return;
}
// Get storage path
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Find photos without dimensions
const photos = await knex('photos')
.whereNull('width')
.orWhereNull('height')
.select('id', 'path', 'filename', 'media_type');
console.log(`[Migration 064] Found ${photos.length} photos without dimensions`);
if (photos.length === 0) {
return;
}
// Import sharp dynamically (only needed during migration)
let sharp;
try {
sharp = require('sharp');
} catch (err) {
console.error('[Migration 064] Sharp not available, skipping backfill:', err.message);
return;
}
let updated = 0;
let failed = 0;
for (const photo of photos) {
try {
// Skip videos - they need ffprobe for metadata
if (photo.media_type === 'video') {
continue;
}
// Construct the full file path
let fullPath;
if (photo.path) {
// Path is relative to events/active directory
fullPath = path.join(storagePath, 'events/active', photo.path);
} else {
console.warn(`[Migration 064] Photo ${photo.id} (${photo.filename}) has no path, skipping`);
continue;
}
// Check if file exists
if (!fs.existsSync(fullPath)) {
console.warn(`[Migration 064] Photo ${photo.id} file not found: ${fullPath}`);
failed++;
continue;
}
// Extract dimensions using sharp
const metadata = await sharp(fullPath).metadata();
if (metadata.width && metadata.height) {
await knex('photos')
.where('id', photo.id)
.update({
width: metadata.width,
height: metadata.height
});
updated++;
if (updated % 50 === 0) {
console.log(`[Migration 064] Updated ${updated} photos...`);
}
} else {
console.warn(`[Migration 064] Could not extract dimensions for photo ${photo.id}`);
failed++;
}
} catch (err) {
console.error(`[Migration 064] Error processing photo ${photo.id}:`, err.message);
failed++;
}
}
console.log(`[Migration 064] Completed: ${updated} updated, ${failed} failed`);
};
exports.down = async function(knex) {
// This migration only adds data, no rollback needed
// We don't want to null out dimensions on rollback as they're still valid
console.log('[Migration 064] Rollback: No action needed (data-only migration)');
};
@@ -0,0 +1,131 @@
/**
* Migration: Add header_style and hero_divider_style columns
*
* This migration decouples the hero header style from gallery layout,
* allowing any combination of header style with any layout type.
*/
exports.up = async function(knex) {
console.log('[Migration 065] Adding header_style and hero_divider_style columns');
// Check if columns already exist
const hasHeaderStyle = await knex.schema.hasColumn('events', 'header_style');
const hasDividerStyle = await knex.schema.hasColumn('events', 'hero_divider_style');
if (!hasHeaderStyle) {
await knex.schema.alterTable('events', (table) => {
table.string('header_style', 20).defaultTo('standard');
});
console.log('[Migration 065] Added header_style column');
}
if (!hasDividerStyle) {
await knex.schema.alterTable('events', (table) => {
table.string('hero_divider_style', 20).defaultTo('wave');
});
console.log('[Migration 065] Added hero_divider_style column');
}
// Migrate existing events with hero layout in color_theme
console.log('[Migration 065] Migrating existing hero layouts...');
const events = await knex('events')
.whereNotNull('color_theme')
.select('id', 'color_theme');
let migratedCount = 0;
for (const event of events) {
try {
// Skip if color_theme is not JSON
if (!event.color_theme || !event.color_theme.startsWith('{')) {
continue;
}
const theme = JSON.parse(event.color_theme);
// Check if this event uses hero layout
if (theme.galleryLayout === 'hero') {
// Migrate: set headerStyle to 'hero' and galleryLayout to 'grid'
const updatedTheme = {
...theme,
headerStyle: 'hero',
galleryLayout: 'grid',
heroDividerStyle: theme.heroDividerStyle || 'wave'
};
await knex('events')
.where('id', event.id)
.update({
color_theme: JSON.stringify(updatedTheme),
header_style: 'hero',
hero_divider_style: theme.heroDividerStyle || 'wave'
});
migratedCount++;
}
} catch (err) {
// Invalid JSON in color_theme, skip
console.warn(`[Migration 065] Could not parse color_theme for event ${event.id}: ${err.message}`);
}
}
console.log(`[Migration 065] Migrated ${migratedCount} events from hero layout`);
console.log('[Migration 065] Completed');
};
exports.down = async function(knex) {
console.log('[Migration 065] Removing header_style and hero_divider_style columns');
// First, migrate any hero header styles back to hero layout
const events = await knex('events')
.where('header_style', 'hero')
.whereNotNull('color_theme')
.select('id', 'color_theme');
for (const event of events) {
try {
if (!event.color_theme || !event.color_theme.startsWith('{')) {
continue;
}
const theme = JSON.parse(event.color_theme);
// Revert: set galleryLayout back to 'hero'
const revertedTheme = {
...theme,
galleryLayout: 'hero'
};
// Remove the new properties
delete revertedTheme.headerStyle;
delete revertedTheme.heroDividerStyle;
await knex('events')
.where('id', event.id)
.update({
color_theme: JSON.stringify(revertedTheme)
});
} catch (err) {
console.warn(`[Migration 065] Could not revert color_theme for event ${event.id}: ${err.message}`);
}
}
// Remove the columns
const hasHeaderStyle = await knex.schema.hasColumn('events', 'header_style');
const hasDividerStyle = await knex.schema.hasColumn('events', 'hero_divider_style');
if (hasHeaderStyle) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('header_style');
});
}
if (hasDividerStyle) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('hero_divider_style');
});
}
console.log('[Migration 065] Rollback completed');
};
@@ -0,0 +1,48 @@
/**
* Migration: Add hero image anchor position and category-specific hero images
*
* Issue #162: Add hero_image_anchor column to events table for controlling
* how hero images are cropped (top/center/bottom)
*
* Issue #163: Add hero_photo_id column to photo_categories table for
* category-specific hero images
*/
exports.up = async function(knex) {
// Add hero_image_anchor to events table (Issue #162)
const hasHeroAnchor = await knex.schema.hasColumn('events', 'hero_image_anchor');
if (!hasHeroAnchor) {
await knex.schema.alterTable('events', function(table) {
// Values: 'top', 'center', 'bottom' - defaults to 'center' for backward compatibility
table.string('hero_image_anchor', 10).defaultTo('center');
});
console.log('Added hero_image_anchor column to events table');
}
// Add hero_photo_id to photo_categories table (Issue #163)
const hasCategoryHero = await knex.schema.hasColumn('photo_categories', 'hero_photo_id');
if (!hasCategoryHero) {
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
});
console.log('Added hero_photo_id column to photo_categories table');
}
};
exports.down = async function(knex) {
// Remove hero_image_anchor from events table
const hasHeroAnchor = await knex.schema.hasColumn('events', 'hero_image_anchor');
if (hasHeroAnchor) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('hero_image_anchor');
});
}
// Remove hero_photo_id from photo_categories table
const hasCategoryHero = await knex.schema.hasColumn('photo_categories', 'hero_photo_id');
if (hasCategoryHero) {
await knex.schema.alterTable('photo_categories', function(table) {
table.dropColumn('hero_photo_id');
});
}
};
@@ -0,0 +1,32 @@
/**
* Migration: Expand hero_image_anchor column to support focal point percentages
*
* Changes string(10) to string(20) so values like "100% 100%" (9 chars) fit
* with room to spare. Existing 'top', 'center', 'bottom' values are preserved.
*/
exports.up = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'hero_image_anchor');
if (!hasColumn) {
// Column doesn't exist yet nothing to expand
return;
}
// SQLite doesn't truly support ALTER COLUMN, but Knex handles the
// rebuild-table strategy internally when we call alterTable.
await knex.schema.alterTable('events', function(table) {
table.string('hero_image_anchor', 20).defaultTo('center').alter();
});
console.log('Expanded hero_image_anchor column to string(20)');
};
exports.down = async function(knex) {
const hasColumn = await knex.schema.hasColumn('events', 'hero_image_anchor');
if (!hasColumn) {
return;
}
await knex.schema.alterTable('events', function(table) {
table.string('hero_image_anchor', 10).defaultTo('center').alter();
});
};
@@ -0,0 +1,54 @@
const DEFAULT_AI_AGENTS = [
'GPTBot',
'ChatGPT-User',
'Google-Extended',
'Claude-Web',
'Anthropic-AI',
'CCBot',
'Bytespider',
'FacebookBot',
'Omgilibot',
'Diffbot',
'PetalBot',
'Amazonbot',
'PerplexityBot',
'YouBot',
'Applebot-Extended'
];
exports.up = async function(knex) {
const defaults = [
{ setting_key: 'seo_allow_indexing', setting_value: JSON.stringify(false), setting_type: 'seo' },
{ setting_key: 'seo_block_ai_crawlers', setting_value: JSON.stringify(true), setting_type: 'seo' },
{ setting_key: 'seo_block_social_bots', setting_value: JSON.stringify(false), setting_type: 'seo' },
{ setting_key: 'seo_blocked_ai_agents', setting_value: JSON.stringify(DEFAULT_AI_AGENTS), setting_type: 'seo' },
{ setting_key: 'seo_custom_rules', setting_value: JSON.stringify([]), setting_type: 'seo' },
{ setting_key: 'seo_meta_noindex', setting_value: JSON.stringify(true), setting_type: 'seo' },
{ setting_key: 'seo_meta_nofollow', setting_value: JSON.stringify(false), setting_type: 'seo' },
{ setting_key: 'seo_meta_noai', setting_value: JSON.stringify(true), setting_type: 'seo' },
{ setting_key: 'seo_sitemap_url', setting_value: JSON.stringify(''), setting_type: 'seo' }
];
for (const setting of defaults) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() });
}
}
};
exports.down = async function(knex) {
await knex('app_settings')
.whereIn('setting_key', [
'seo_allow_indexing',
'seo_block_ai_crawlers',
'seo_block_social_bots',
'seo_blocked_ai_agents',
'seo_custom_rules',
'seo_meta_noindex',
'seo_meta_nofollow',
'seo_meta_noai',
'seo_sitemap_url'
])
.del();
};
@@ -0,0 +1,22 @@
/**
* Migration 069: Add hero image path to photos table
* - photos.hero_path: path to hero-optimized image (1920x1080) for gallery headers
*/
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 069_add_hero_path');
// photos.hero_path (nullable - path to hero-optimized image)
await addColumnIfNotExists(knex, 'photos', 'hero_path', (table) => {
table.string('hero_path', 512);
});
console.log('Migration 069_add_hero_path completed');
};
exports.down = async function(knex) {
console.log('Rollback: 069_add_hero_path');
// Keep columns (safe rollback not removing data). Intentionally no-op.
};
@@ -0,0 +1,161 @@
/**
* Migration 070: Add update notification settings and email template
* - Settings for email notifications when new versions are available
* - Email template for version update notifications
*/
exports.up = async function(knex) {
console.log('Running migration: 070_add_update_notification_settings');
// Add app_settings for update notifications
const settings = [
{
setting_key: 'update_email_notifications_enabled',
setting_value: JSON.stringify(false),
setting_type: 'notifications'
},
{
setting_key: 'update_email_recipients',
setting_value: JSON.stringify(''), // Comma-separated emails, or empty for all admin emails
setting_type: 'notifications'
},
{
setting_key: 'last_notified_version',
setting_value: JSON.stringify(''),
setting_type: 'notifications'
}
];
for (const setting of settings) {
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
if (!exists) {
await knex('app_settings').insert({ ...setting, updated_at: knex.fn.now() });
}
}
// Check if email template already exists
const existingTemplate = await knex('email_templates')
.where('template_key', 'version_update_available')
.first();
if (!existingTemplate) {
await knex('email_templates').insert({
template_key: 'version_update_available',
subject_en: 'PicPeak Update Available: Version {{new_version}}',
subject_de: 'PicPeak Update verfugbar: Version {{new_version}}',
body_html_en: `
<h2>A New Version of PicPeak is Available</h2>
<p>Great news! A new version of PicPeak is available for your installation.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Current Version:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>New Version:</strong> {{new_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Channel:</strong> {{channel}}</p>
</div>
<h3>What's New?</h3>
<p>Check the release notes to see what's included in this update:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{release_notes_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">View Release Notes</a>
</div>
<h3>How to Update</h3>
<p>To update your installation, log in to the admin panel and click on the "Update Available" notification. You'll find environment-specific instructions there.</p>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Reminder:</strong> Always backup your database before updating to ensure you can recover if anything goes wrong.</p>
</div>
<p>Best regards,<br>
Your PicPeak Installation</p>`,
body_text_en: `A New Version of PicPeak is Available
Great news! A new version of PicPeak is available for your installation.
Current Version: {{current_version}}
New Version: {{new_version}}
Channel: {{channel}}
What's New?
Check the release notes to see what's included in this update:
{{release_notes_url}}
How to Update
To update your installation, log in to the admin panel and click on the "Update Available" notification. You'll find environment-specific instructions there.
REMINDER: Always backup your database before updating to ensure you can recover if anything goes wrong.
Best regards,
Your PicPeak Installation`,
body_html_de: `
<h2>Eine neue Version von PicPeak ist verfugbar</h2>
<p>Gute Neuigkeiten! Eine neue Version von PicPeak ist fur Ihre Installation verfugbar.</p>
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Aktuelle Version:</strong> {{current_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Neue Version:</strong> {{new_version}}</p>
<p style="margin: 10px 0 0 0;"><strong>Kanal:</strong> {{channel}}</p>
</div>
<h3>Was ist neu?</h3>
<p>Schauen Sie sich die Versionshinweise an, um zu sehen, was in diesem Update enthalten ist:</p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{release_notes_url}}" style="display: inline-block; padding: 14px 35px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Versionshinweise anzeigen</a>
</div>
<h3>So aktualisieren Sie</h3>
<p>Um Ihre Installation zu aktualisieren, melden Sie sich im Admin-Panel an und klicken Sie auf die Benachrichtigung "Update verfugbar". Dort finden Sie umgebungsspezifische Anweisungen.</p>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Erinnerung:</strong> Erstellen Sie immer ein Backup Ihrer Datenbank, bevor Sie aktualisieren, um sicherzustellen, dass Sie im Fehlerfall wiederherstellen konnen.</p>
</div>
<p>Mit freundlichen Grussen,<br>
Ihre PicPeak-Installation</p>`,
body_text_de: `Eine neue Version von PicPeak ist verfugbar
Gute Neuigkeiten! Eine neue Version von PicPeak ist fur Ihre Installation verfugbar.
Aktuelle Version: {{current_version}}
Neue Version: {{new_version}}
Kanal: {{channel}}
Was ist neu?
Schauen Sie sich die Versionshinweise an, um zu sehen, was in diesem Update enthalten ist:
{{release_notes_url}}
So aktualisieren Sie
Um Ihre Installation zu aktualisieren, melden Sie sich im Admin-Panel an und klicken Sie auf die Benachrichtigung "Update verfugbar". Dort finden Sie umgebungsspezifische Anweisungen.
ERINNERUNG: Erstellen Sie immer ein Backup Ihrer Datenbank, bevor Sie aktualisieren, um sicherzustellen, dass Sie im Fehlerfall wiederherstellen konnen.
Mit freundlichen Grussen,
Ihre PicPeak-Installation`,
variables: JSON.stringify(['current_version', 'new_version', 'channel', 'release_notes_url'])
});
}
console.log('Migration 070_add_update_notification_settings completed');
};
exports.down = async function(knex) {
console.log('Rollback: 070_add_update_notification_settings');
// Remove settings
await knex('app_settings')
.whereIn('setting_key', [
'update_email_notifications_enabled',
'update_email_recipients',
'last_notified_version'
])
.del();
// Remove email template
await knex('email_templates')
.where('template_key', 'version_update_available')
.del();
};
@@ -0,0 +1,42 @@
/**
* Migration 071: Add captured_at column to photos table
* - Stores the original capture date from EXIF metadata
* - Enables sorting photos by capture date instead of upload date
*/
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 071_add_captured_at');
// Add captured_at column to photos table
await addColumnIfNotExists(knex, 'photos', 'captured_at', (table) => {
table.datetime('captured_at').nullable();
});
// Add index for sorting performance
const indexExists = await knex.schema.hasIndex
? await knex.schema.hasIndex('photos', 'idx_photos_captured_at')
: false;
if (!indexExists) {
// Use raw query for index creation with IF NOT EXISTS
const client = knex.client.config.client;
if (client === 'pg') {
await knex.raw('CREATE INDEX IF NOT EXISTS idx_photos_captured_at ON photos(captured_at)');
} else if (client === 'sqlite3' || client === 'better-sqlite3') {
// SQLite doesn't support IF NOT EXISTS for indexes, so we need to check first
const existingIndexes = await knex.raw("SELECT name FROM sqlite_master WHERE type='index' AND name='idx_photos_captured_at'");
if (existingIndexes.length === 0) {
await knex.raw('CREATE INDEX idx_photos_captured_at ON photos(captured_at)');
}
}
}
console.log('Migration 071_add_captured_at completed');
};
exports.down = async function(knex) {
console.log('Rollback: 071_add_captured_at');
// Keep column for safe rollback (intentionally no-op)
};
@@ -0,0 +1,13 @@
exports.up = async function(knex) {
await knex.schema.alterTable('events', (table) => {
table.string('host_email', 255).nullable().alter();
table.string('admin_email', 255).nullable().alter();
});
};
exports.down = async function(knex) {
await knex.schema.alterTable('events', (table) => {
table.string('host_email', 255).notNullable().defaultTo('').alter();
table.string('admin_email', 255).notNullable().defaultTo('').alter();
});
};
@@ -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');
};
@@ -1,22 +1,27 @@
exports.up = async function(knex) {
// Add photo_counter column to photo_categories table
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('photo_counter').defaultTo(0).notNullable();
});
// Check if photo_counter column already exists to make migration idempotent
const hasPhotoCounter = await knex.schema.hasColumn('photo_categories', 'photo_counter');
// Initialize counters based on existing photos
const categories = await knex('photo_categories').select('id');
for (const category of categories) {
const photoCount = await knex('photos')
.where('category_id', category.id)
.count('id as count')
.first();
if (photoCount && photoCount.count > 0) {
await knex('photo_categories')
.where('id', category.id)
.update({ photo_counter: photoCount.count });
if (!hasPhotoCounter) {
// Add photo_counter column to photo_categories table
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('photo_counter').defaultTo(0).notNullable();
});
// Initialize counters based on existing photos
const categories = await knex('photo_categories').select('id');
for (const category of categories) {
const photoCount = await knex('photos')
.where('category_id', category.id)
.count('id as count')
.first();
if (photoCount && photoCount.count > 0) {
await knex('photo_categories')
.where('id', category.id)
.update({ photo_counter: photoCount.count });
}
}
}
};
@@ -1,23 +1,33 @@
exports.up = async function(knex) {
// Add language-specific columns to email_templates
await knex.schema.alterTable('email_templates', function(table) {
// Add English versions (rename existing columns for consistency)
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
// Add German versions
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Check which columns already exist to make migration idempotent
const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
const hasSubjectDe = await knex.schema.hasColumn('email_templates', 'subject_de');
const hasSubjectOriginal = await knex.schema.hasColumn('email_templates', 'subject');
// Copy existing values to German columns as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
// Only rename columns if they haven't been renamed yet
if (hasSubjectOriginal && !hasSubjectEn) {
await knex.schema.alterTable('email_templates', function(table) {
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
});
}
// Only add German columns if they don't exist
if (!hasSubjectDe) {
await knex.schema.alterTable('email_templates', function(table) {
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Copy existing values to German columns as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
}
};
exports.down = async function(knex) {
+17 -6
View File
@@ -67,26 +67,37 @@ async function runMigrationSafely(filepath) {
const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath);
const filename = path.basename(filepath);
if (migration.up) {
console.log(`Running migration: ${filepath}`);
// Run migration in a transaction if possible
// IMPORTANT: Include the migrations table insert INSIDE the transaction
// to ensure atomicity between schema changes and tracking
if (db.client.config.client === 'pg') {
await db.transaction(async (trx) => {
await migration.up(trx);
// Insert migration record inside transaction for atomicity
await trx('migrations').insert({ filename });
});
} else {
await migration.up(db);
await db('migrations').insert({ filename });
}
await db('migrations').insert({ filename });
console.log(`Migration ${filepath} completed successfully`);
}
} catch (error) {
// Check if error is because schema already exists
if (error.code === '42P07' || // PostgreSQL: relation already exists
error.code === 'SQLITE_ERROR' && error.message.includes('already exists')) {
// PostgreSQL error codes:
// - 42P07: duplicate_table (relation already exists)
// - 42701: duplicate_column (column already exists)
// - 42710: duplicate_object (constraint, index, etc. already exists)
// - 23505: unique_violation (migration record already exists)
const schemaExistsErrors = ['42P07', '42701', '42710', '23505'];
const isSQLiteAlreadyExists = error.code === 'SQLITE_ERROR' && error.message.includes('already exists');
if (schemaExistsErrors.includes(error.code) || isSQLiteAlreadyExists) {
console.log(`Migration ${filepath} - schema already exists, marking as applied`);
await markMigrationAsApplied(path.basename(filepath));
} else {
+14 -3
View File
@@ -26,11 +26,22 @@ async function runMigration(filepath) {
const migrationPath = path.join(__dirname, filepath);
const migration = require(migrationPath);
const filename = path.basename(filepath);
if (migration.up) {
console.log(`Running migration: ${filepath}`);
await migration.up(db);
await db('migrations').insert({ filename });
// Run migration in a transaction if PostgreSQL to ensure atomicity
// between schema changes and migration tracking
if (db.client.config.client === 'pg') {
await db.transaction(async (trx) => {
await migration.up(trx);
await trx('migrations').insert({ filename });
});
} else {
await migration.up(db);
await db('migrations').insert({ filename });
}
console.log(`Migration ${filepath} completed`);
}
}
+843 -933
View File
File diff suppressed because it is too large Load Diff
+9 -3
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "2.2.3",
"version": "3.23.0-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -8,6 +8,7 @@
"dev": "nodemon server.js",
"migrate": "node migrations/run-migrations.js",
"migrate:safe": "node migrations/run-migrations-safe.js",
"generate:watermarks": "node scripts/generate-watermarks.js",
"test": "jest",
"lint": "eslint src/"
},
@@ -24,6 +25,7 @@
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"exifr": "^7.1.3",
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
@@ -64,7 +66,11 @@
"tar-fs": "2.1.4"
},
"glob": "^11.1.0",
"body-parser": "^2.2.1",
"js-yaml": "^4.1.1"
"js-yaml": "^4.1.1",
"fast-xml-parser": ">=5.3.8",
"qs": ">=6.14.2",
"tar": ">=7.5.11",
"brace-expansion": ">=5.0.0",
"minimatch": ">=9.0.7"
}
}
+29 -18
View File
@@ -44,28 +44,39 @@ async function createAdmin() {
.orWhere('username', username)
.first();
if (existingUser) {
console.error(`Error: User with email "${email}" or username "${username}" already exists`);
process.exit(1);
}
// Hash password
const passwordHash = await bcrypt.hash(password, 10);
// Create admin user
await db('admin_users').insert({
username,
email,
password_hash: passwordHash,
is_active: true,
created_at: new Date(),
updated_at: new Date()
});
if (existingUser) {
// Update existing user's password
await db('admin_users')
.where('id', existingUser.id)
.update({
password_hash: passwordHash,
updated_at: new Date()
});
console.log(`✅ Admin user created successfully!`);
console.log(` Email: ${email}`);
console.log(` Username: ${username}`);
console.log(` Login URL: ${process.env.ADMIN_URL || 'http://localhost:3000'}/admin/login`);
console.log(`✅ Admin user updated successfully!`);
console.log(` Email: ${existingUser.email}`);
console.log(` Username: ${existingUser.username}`);
console.log(` Password has been reset to the provided value`);
console.log(` Login URL: ${process.env.ADMIN_URL || 'http://localhost:3000'}/admin/login`);
} else {
// Create new admin user
await db('admin_users').insert({
username,
email,
password_hash: passwordHash,
is_active: true,
created_at: new Date(),
updated_at: new Date()
});
console.log(`✅ Admin user created successfully!`);
console.log(` Email: ${email}`);
console.log(` Username: ${username}`);
console.log(` Login URL: ${process.env.ADMIN_URL || 'http://localhost:3000'}/admin/login`);
}
process.exit(0);
} catch (error) {
+161
View File
@@ -0,0 +1,161 @@
#!/usr/bin/env node
/**
* Script to generate pre-watermarked versions for existing photos
* This is a one-time migration script to populate watermarks for photos
* that existed before the pre-generation feature was implemented.
*
* Usage: node scripts/generate-watermarks.js [eventId]
*
* Options:
* eventId - Optional: Only generate watermarks for a specific event
*
* Examples:
* node scripts/generate-watermarks.js # Generate for all photos
* node scripts/generate-watermarks.js 5 # Generate for event ID 5
*/
const path = require('path');
const { db } = require('../src/database/db');
const watermarkService = require('../src/services/watermarkService');
const watermarkGeneratorService = require('../src/services/watermarkGeneratorService');
async function generateWatermarks(eventId = null) {
try {
console.log('='.repeat(60));
console.log('PicPeak Watermark Generation Script');
console.log('='.repeat(60));
// Check if watermarking is enabled
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
console.log('\nWatermarking is currently DISABLED in settings.');
console.log('Enable watermarking in Admin > Branding settings first.');
console.log('Exiting without generating watermarks.');
process.exit(0);
}
console.log('\nWatermark Settings:');
console.log(` Enabled: ${settings.enabled}`);
console.log(` Position: ${settings.position}`);
console.log(` Opacity: ${settings.opacity}%`);
console.log(` Size: ${settings.size}%`);
console.log(` Logo: ${settings.logoPath || '(using text fallback)'}`);
// Build query
let query = db('photos')
.join('events', 'photos.event_id', 'events.id')
.whereNull('photos.watermark_path')
.whereNot(function() {
this.where('photos.media_type', 'video')
.orWhere('photos.mime_type', 'like', 'video/%');
})
.select(
'photos.id',
'photos.filename',
'photos.event_id',
'events.event_name'
);
if (eventId) {
query = query.where('photos.event_id', eventId);
console.log(`\nFiltering to event ID: ${eventId}`);
}
const photos = await query;
if (photos.length === 0) {
console.log('\nNo photos found without watermarks.');
if (eventId) {
console.log(`(Checked event ID: ${eventId})`);
}
console.log('All photos already have pre-generated watermarks or watermarking is disabled.');
process.exit(0);
}
console.log(`\nFound ${photos.length} photos without watermarks.`);
// Group by event for display
const eventCounts = {};
photos.forEach(p => {
eventCounts[p.event_name] = (eventCounts[p.event_name] || 0) + 1;
});
console.log('\nPhotos by event:');
Object.entries(eventCounts).forEach(([name, count]) => {
console.log(` ${name}: ${count} photos`);
});
console.log('\nStarting watermark generation...\n');
let successCount = 0;
let failCount = 0;
const startTime = Date.now();
// Process photos with progress display
for (let i = 0; i < photos.length; i++) {
const photo = photos[i];
const progress = Math.round(((i + 1) / photos.length) * 100);
process.stdout.write(`\r[${progress}%] Processing photo ${i + 1}/${photos.length}: ${photo.filename.substring(0, 30)}...`);
try {
const result = await watermarkGeneratorService.generateForPhoto(photo.id);
if (result.success) {
successCount++;
} else {
failCount++;
console.log(`\n Failed: ${photo.filename} - ${result.error}`);
}
} catch (error) {
failCount++;
console.log(`\n Error: ${photo.filename} - ${error.message}`);
}
}
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
console.log('\n');
console.log('='.repeat(60));
console.log('Watermark Generation Complete');
console.log('='.repeat(60));
console.log(` Total processed: ${photos.length}`);
console.log(` Successful: ${successCount}`);
console.log(` Failed: ${failCount}`);
console.log(` Duration: ${duration} seconds`);
console.log(` Average: ${(photos.length / parseFloat(duration)).toFixed(1)} photos/second`);
if (failCount > 0) {
console.log('\nSome watermarks failed to generate. Check the errors above.');
console.log('You can re-run this script to retry failed photos.');
}
process.exit(failCount > 0 ? 1 : 0);
} catch (error) {
console.error('\nFatal error:', error.message);
console.error(error.stack);
process.exit(1);
}
}
// Parse command line arguments
const args = process.argv.slice(2);
const eventId = args[0] ? parseInt(args[0], 10) : null;
if (args[0] && isNaN(eventId)) {
console.error('Error: eventId must be a number');
console.log('Usage: node scripts/generate-watermarks.js [eventId]');
process.exit(1);
}
// Run the script
generateWatermarks(eventId)
.then(() => {
process.exit(0);
})
.catch(error => {
console.error('Unhandled error:', error);
process.exit(1);
});
+6 -4
View File
@@ -36,12 +36,14 @@ async function showAdminCredentials(resetPassword = false) {
.where('id', admin.id)
.update({
password_hash: passwordHash,
must_change_password: true,
updated_at: new Date()
});
// Password logging removed for security - check logs or database if needed
console.log('Password: [NEWLY RESET - stored in database]');
console.log('\n⚠️ IMPORTANT: New password has been set in database!');
console.log(`Password: ${newPassword}`);
console.log('\n⚠️ IMPORTANT:');
console.log('1. Save this password securely - it will not be shown again');
console.log('2. You will be required to change it on next login');
} else {
console.log('Password: [hidden - use --reset flag to generate new password]');
}
+112 -22
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 = `
@@ -252,10 +263,29 @@ function renderBrandFooter(branding) {
</footer>`;
}
function buildSeoMetaTags(seoSettings) {
const tags = [];
const robotsDirectives = [];
if (seoSettings.seo_meta_noindex) robotsDirectives.push('noindex');
if (seoSettings.seo_meta_nofollow) robotsDirectives.push('nofollow');
if (robotsDirectives.length > 0) {
tags.push(`<meta name="robots" content="${robotsDirectives.join(', ')}" />`);
}
if (seoSettings.seo_meta_noai) {
tags.push('<meta name="robots" content="noai, noimageai" />');
}
return tags.join('\n ');
}
function buildPublicSiteDocument(payload) {
const inlineStyles = composeInlineStyles(payload);
const header = renderBrandHeader(payload.branding);
const footer = renderBrandFooter(payload.branding);
const seoMeta = payload.seoSettings ? buildSeoMetaTags(payload.seoSettings) : '';
return `<!DOCTYPE html>
<html lang="en">
@@ -263,8 +293,9 @@ 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" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
@@ -296,6 +327,21 @@ async function handlePublicSiteRequest(req, res, next) {
return;
}
// Inject SEO meta settings into payload
try {
const seoRows = await db('app_settings')
.where('setting_type', 'seo')
.whereIn('setting_key', ['seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai'])
.select('setting_key', 'setting_value');
const seoSettings = {};
for (const row of seoRows) {
let val = row.setting_value;
if (typeof val === 'string') { try { val = JSON.parse(val); } catch {} }
seoSettings[row.setting_key] = val;
}
payload.seoSettings = seoSettings;
} catch {}
const document = buildPublicSiteDocument(payload);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
@@ -324,8 +370,22 @@ async function initializeRateLimiters() {
}
// Note: Rate limiters will be initialized after database connection
app.use(express.json({ limit: '10gb' }));
app.use(express.urlencoded({ extended: true, limit: '10gb' }));
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) => {
@@ -351,8 +411,23 @@ app.use('/api/admin', sessionTimeoutMiddleware);
// Middleware to set CORS headers for static files
const setCorsHeaders = (req, res, next) => {
res.header('Access-Control-Allow-Origin', req.headers.origin || '*');
res.header('Access-Control-Allow-Credentials', 'true');
const origin = req.headers.origin;
const staticAllowedOrigins = [
process.env.FRONTEND_URL || 'http://localhost:3005',
process.env.ADMIN_URL || 'http://localhost:3005'
];
if (process.env.NODE_ENV === 'development') {
staticAllowedOrigins.push(
'http://localhost:5173',
'http://localhost:3002',
'http://localhost:3001',
'http://localhost:3000'
);
}
if (origin && staticAllowedOrigins.indexOf(origin) !== -1) {
res.header('Access-Control-Allow-Origin', origin);
res.header('Access-Control-Allow-Credentials', 'true');
}
res.header('Cross-Origin-Resource-Policy', 'cross-origin');
next();
};
@@ -395,24 +470,37 @@ if (process.env.NODE_ENV === 'development') {
});
}
// robots.txt endpoint (dynamic, served from DB settings)
const { generateRobotsTxt } = require('./src/services/robotsTxtService');
app.get('/robots.txt', async (req, res) => {
try {
const robotsTxt = await generateRobotsTxt();
res.setHeader('Content-Type', 'text/plain');
res.setHeader('Cache-Control', 'public, max-age=3600');
res.status(200).send(robotsTxt);
} catch (error) {
logger.error('Failed to generate robots.txt', { error: error.message });
// Safe default for a private photo platform
res.setHeader('Content-Type', 'text/plain');
res.status(200).send('User-agent: *\nDisallow: /\n');
}
});
// Health check endpoint
app.get('/health', async (req, res) => {
try {
// Check database connectivity
await db.raw('SELECT 1');
res.json({
status: 'ok',
database: 'connected',
timestamp: new Date().toISOString()
res.json({
status: 'ok',
timestamp: new Date().toISOString()
});
} catch (error) {
logger.error('Health check failed:', error);
res.status(503).json({
status: 'error',
database: 'disconnected',
error: error.message,
timestamp: new Date().toISOString()
res.status(503).json({
status: 'error',
timestamp: new Date().toISOString()
});
}
});
@@ -432,11 +520,13 @@ app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'
app.use('/api/admin/feedback', require('./src/routes/adminFeedback'));
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'));
app.use('/api/admin/photos', require('./src/routes/adminPhotos'));
app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
app.use('/api/admin/users', require('./src/routes/adminUsers'));
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
app.use('/api/invite', require('./src/routes/acceptInvite'));
app.use('/api/public/settings', require('./src/routes/publicSettings'));
app.use('/api/public', require('./src/routes/publicCMS'));
+4 -3
View File
@@ -168,9 +168,10 @@ async function galleryAuth(req, res, next) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
// Check if gallery has expired
if (new Date(event.expires_at) < new Date()) {
return res.status(410).json({
// Check if gallery has expired (only if expires_at is set)
// Galleries with null expires_at never expire
if (event.expires_at && new Date(event.expires_at) < new Date()) {
return res.status(410).json({
error: 'Gallery has expired',
code: 'GALLERY_EXPIRED'
});
+2 -1
View File
@@ -102,8 +102,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',
+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)
+4 -2
View File
@@ -122,13 +122,15 @@ 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
});
// Log activity
+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 = '') {
+77 -17
View File
@@ -106,42 +106,53 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
// Update a category
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().withMessage('Category name is required')
body('name').notEmpty().withMessage('Category name is required'),
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
if (value === null || value === undefined) return true;
return Number.isInteger(Number(value));
}).withMessage('hero_photo_id must be an integer or null')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { name } = req.body;
const { name, hero_photo_id } = req.body;
const category = await db('photo_categories').where('id', id).first();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
const updateData = {
name,
slug: name.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
};
// Update hero_photo_id if provided (including null to clear it)
if (Object.prototype.hasOwnProperty.call(req.body, 'hero_photo_id')) {
updateData.hero_photo_id = hero_photo_id || null;
}
await db('photo_categories')
.where('id', id)
.update({
name,
slug: name.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim()
});
.update(updateData);
const updated = await db('photo_categories').where('id', id).first();
// Log activity
await logActivity('category_updated',
{ categoryName: name },
{ categoryName: name, heroPhotoId: hero_photo_id },
category.event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(updated);
} catch (error) {
console.error('Error updating category:', error);
@@ -149,6 +160,55 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
}
});
// Set category hero photo (#163)
router.put('/:id/hero', adminAuth, requirePermission('settings.edit'), [
body('hero_photo_id').optional({ nullable: true }).custom((value) => {
if (value === null || value === undefined) return true;
return Number.isInteger(Number(value));
}).withMessage('hero_photo_id must be an integer or null')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const { hero_photo_id } = req.body;
const category = await db('photo_categories').where('id', id).first();
if (!category) {
return res.status(404).json({ error: 'Category not found' });
}
// If hero_photo_id is provided, verify it belongs to a photo in this category
if (hero_photo_id) {
const photo = await db('photos').where('id', hero_photo_id).first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
}
await db('photo_categories')
.where('id', id)
.update({ hero_photo_id: hero_photo_id || null });
const updated = await db('photo_categories').where('id', id).first();
// Log activity
await logActivity('category_hero_updated',
{ categoryName: category.name, heroPhotoId: hero_photo_id },
category.event_id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(updated);
} catch (error) {
console.error('Error updating category hero:', error);
res.status(500).json({ error: 'Failed to update category hero' });
}
});
// Delete a category
router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
+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
});
+258
View File
@@ -0,0 +1,258 @@
/**
* Admin Event Types Routes
* CRUD operations for managing customizable event types
*
* @module routes/adminEventTypes
*/
const express = require('express');
const { body, param, validationResult } = require('express-validator');
const { logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const eventTypeService = require('../services/eventTypeService');
const logger = require('../utils/logger');
const router = express.Router();
/**
* GET /admin/event-types
* Get all event types (for admin management)
*/
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const includeInactive = req.query.includeInactive === 'true';
const eventTypes = await eventTypeService.getAllEventTypes({
activeOnly: !includeInactive
});
res.json({ eventTypes });
} catch (error) {
logger.error('Error fetching event types:', { error: error.message });
res.status(500).json({ error: 'Failed to fetch event types' });
}
});
/**
* GET /admin/event-types/active
* Get only active event types (for dropdowns/selection)
*/
router.get('/active', adminAuth, async (req, res) => {
try {
const eventTypes = await eventTypeService.getActiveEventTypes();
res.json({ eventTypes });
} catch (error) {
logger.error('Error fetching active event types:', { error: error.message });
res.status(500).json({ error: 'Failed to fetch event types' });
}
});
/**
* GET /admin/event-types/:id
* Get a single event type by ID
*/
router.get('/:id', adminAuth, requirePermission('settings.view'), [
param('id').isInt().withMessage('Invalid event type ID')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const eventType = await eventTypeService.getEventTypeById(parseInt(id));
if (!eventType) {
return res.status(404).json({ error: 'Event type not found' });
}
res.json(eventType);
} catch (error) {
logger.error('Error fetching event type:', { error: error.message });
res.status(500).json({ error: 'Failed to fetch event type' });
}
});
/**
* POST /admin/event-types
* Create a new event type
*/
router.post('/', adminAuth, requirePermission('settings.edit'), [
body('name').notEmpty().trim().withMessage('Name is required'),
body('slug_prefix')
.notEmpty()
.trim()
.isLength({ min: 2, max: 50 })
.matches(/^[a-z0-9-]+$/i)
.withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'),
body('emoji').optional().trim(),
body('theme_preset').optional().trim(),
body('theme_config').optional(),
body('display_order').optional().isInt({ min: 0 })
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
name,
slug_prefix,
emoji,
theme_preset,
theme_config,
display_order
} = req.body;
const eventType = await eventTypeService.createEventType({
name,
slug_prefix,
emoji,
theme_preset,
theme_config,
display_order
});
// Log activity
await logActivity('event_type_created',
{ name, slug_prefix },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.status(201).json(eventType);
} catch (error) {
logger.error('Error creating event type:', { error: error.message });
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: 'Failed to create event type' });
}
});
/**
* PUT /admin/event-types/:id
* Update an event type
*/
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
param('id').isInt().withMessage('Invalid event type ID'),
body('name').optional().notEmpty().trim().withMessage('Name cannot be empty'),
body('slug_prefix')
.optional()
.trim()
.isLength({ min: 2, max: 50 })
.matches(/^[a-z0-9-]+$/i)
.withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'),
body('emoji').optional().trim(),
body('theme_preset').optional().trim(),
body('theme_config').optional(),
body('display_order').optional().isInt({ min: 0 }),
body('is_active').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const updates = req.body;
const eventType = await eventTypeService.updateEventType(parseInt(id), updates);
// Log activity
await logActivity('event_type_updated',
{ id, changes: Object.keys(updates) },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json(eventType);
} catch (error) {
logger.error('Error updating event type:', { error: error.message });
if (error.code === 'NOT_FOUND') {
return res.status(404).json({ error: error.message });
}
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: 'Failed to update event type' });
}
});
/**
* DELETE /admin/event-types/:id
* Delete an event type (only non-system types with no events)
*/
router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
param('id').isInt().withMessage('Invalid event type ID')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const result = await eventTypeService.deleteEventType(parseInt(id));
// Log activity
await logActivity('event_type_deleted',
{ id, name: result.deleted.name, slug_prefix: result.deleted.slug_prefix },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Event type deleted successfully' });
} catch (error) {
logger.error('Error deleting event type:', { error: error.message });
if (error.code === 'NOT_FOUND') {
return res.status(404).json({ error: error.message });
}
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE') {
return res.status(400).json({ error: error.message });
}
res.status(500).json({ error: 'Failed to delete event type' });
}
});
/**
* POST /admin/event-types/reorder
* Reorder event types by providing an array of IDs in the desired order
*/
router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
body('orderedIds').isArray().withMessage('orderedIds must be an array'),
body('orderedIds.*').isInt().withMessage('Each ID must be an integer')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { orderedIds } = req.body;
const eventTypes = await eventTypeService.reorderEventTypes(orderedIds);
// Log activity
await logActivity('event_types_reordered',
{ newOrder: orderedIds },
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ eventTypes, message: 'Event types reordered successfully' });
} catch (error) {
logger.error('Error reordering event types:', { error: error.message });
res.status(500).json({ error: 'Failed to reorder event types' });
}
});
module.exports = router;
+7 -3
View File
@@ -6,8 +6,10 @@ const { buildShareLinkVariants } = require('../services/shareLinkService');
const { requirePermission } = require('../middleware/permissions');
// Enhanced event creation with password validation
// Note: This is a partial/reference file - dynamic event type validation should be implemented
// similar to adminEvents.js using eventTypeService.isValidEventType()
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_type').notEmpty().trim(), // Dynamic validation via eventTypeService
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
body('customer_email').isEmail().normalizeEmail(),
@@ -40,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
@@ -103,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)
+378 -55
View File
@@ -1,5 +1,5 @@
const express = require('express');
const { body, query, validationResult } = require('express-validator');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
@@ -9,6 +9,7 @@ const bcrypt = require('bcrypt');
const crypto = require('crypto');
const fs = require('fs').promises;
const path = require('path');
const multer = require('multer');
const { archiveEvent } = require('../services/archiveService');
const { queueEmail } = require('../services/emailProcessor');
const { escapeLikePattern } = require('../utils/sqlSecurity');
@@ -16,7 +17,49 @@ const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
const { validateFileType } = require('../utils/fileSecurityUtils');
const { requireEventOwnership } = require('../middleware/ownership');
// Shared validator for hero_image_anchor accepts legacy keywords or "X% Y%" focal point
const validateHeroImageAnchor = (value) => {
if (['top', 'center', 'bottom'].includes(value)) return true;
if (typeof value === 'string' && /^\d{1,3}%\s+\d{1,3}%$/.test(value)) {
const [x, y] = value.split(/\s+/).map(v => parseInt(v));
if (x >= 0 && x <= 100 && y >= 0 && y <= 100) return true;
}
throw new Error('Must be top, center, bottom, or "X% Y%" (0-100)');
};
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Configure multer for event logo uploads
const eventLogoStorage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(getStoragePath(), 'uploads/logos/events');
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
},
filename: (req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
}
});
const eventLogoUpload = multer({
storage: eventLogoStorage,
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
fileFilter: (req, file, cb) => {
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
return cb(null, true);
} else {
cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed'));
}
}
});
// Helper to get event field requirements from settings
const getEventFieldRequirements = async () => {
@@ -25,14 +68,18 @@ const getEventFieldRequirements = async () => {
.whereIn('setting_key', [
'event_require_customer_name',
'event_require_customer_email',
'event_require_admin_email'
'event_require_admin_email',
'event_require_event_date',
'event_require_expiration'
])
.select('setting_key', 'setting_value');
const requirements = {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
settings.forEach(s => {
@@ -47,6 +94,8 @@ const getEventFieldRequirements = async () => {
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
});
return requirements;
@@ -55,7 +104,9 @@ const getEventFieldRequirements = async () => {
return {
require_customer_name: true,
require_customer_email: true,
require_admin_email: true
require_admin_email: true,
require_event_date: true,
require_expiration: true
};
}
};
@@ -74,6 +125,8 @@ const mapEventForApi = (event) => {
host_email,
customer_name,
customer_email,
password_hash: _ph,
client_password_hash: _cph,
...rest
} = event;
@@ -104,12 +157,18 @@ const hasCustomerContactColumns = async () => {
// Create new event
router.post('/', adminAuth, requirePermission('events.create'), [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_type').notEmpty().trim().custom(async (value) => {
const isValid = await eventTypeService.isValidEventType(value);
if (!isValid) {
throw new Error('Invalid event type');
}
return true;
}),
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
body('event_date').optional({ values: 'falsy' }).isDate(),
body('customer_name').optional().trim(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('admin_email').optional().isEmail().normalizeEmail(),
body('customer_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
body('admin_email').optional({ values: 'falsy' }).isEmail().normalizeEmail(),
body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
const input = req.body.require_password;
@@ -143,7 +202,19 @@ router.post('/', adminAuth, requirePermission('events.create'), [
body('disable_right_click').optional().isBoolean(),
body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim(),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
body('hero_logo_visible').optional().isBoolean(),
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
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),
// Client access settings (#172)
body('client_access_enabled').optional().isBoolean(),
body('client_password').optional().isString()
], async (req, res) => {
try {
logger.debug('Create event request body', { body: req.body });
@@ -182,7 +253,21 @@ router.post('/', adminAuth, requirePermission('events.create'), [
moderate_comments = true,
show_feedback_to_guests = true,
// CSS Template
css_template_id = null
css_template_id = null,
// Hero logo settings
hero_logo_visible = true,
hero_logo_size = 'medium',
hero_logo_position = 'top',
// Header style settings
header_style = 'standard',
hero_divider_style = 'wave',
// Hero image anchor position (#162)
hero_image_anchor = 'center',
// Photo cap
photo_cap = null,
// Client access settings (#172)
client_access_enabled = false,
client_password = null
} = req.body;
const customerName = getCustomerNameFromPayload(req.body);
@@ -201,6 +286,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
if (fieldRequirements.require_admin_email && !admin_email) {
validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' });
}
if (fieldRequirements.require_event_date && !event_date) {
validationErrors.push({ path: 'event_date', msg: 'Event date is required' });
}
if (validationErrors.length > 0) {
return res.status(400).json({ errors: validationErrors });
@@ -245,10 +333,13 @@ router.post('/', adminAuth, requirePermission('events.create'), [
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
.replace(/-+/g, '-') // Replace multiple dashes with single dash
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
const baseSlug = `${event_type}-${processedEventName}-${event_date}`;
// Use event_date in slug if provided, otherwise use random suffix
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`;
let slug = baseSlug;
let counter = 1;
while (await db('events').where({ slug }).first()) {
slug = `${baseSlug}-${counter}`;
counter++;
@@ -256,7 +347,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
// Generate share link respecting configured format
const shareToken = crypto.randomBytes(16).toString('hex');
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds (random placeholder when not required)
const password_hash = requirePassword
@@ -264,15 +355,20 @@ router.post('/', adminAuth, requirePermission('events.create'), [
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date (days after event date)
// Parse YYYY-MM-DD format as local date to avoid timezone issues
let expires_at;
if (event_date.match(/^\d{4}-\d{2}-\d{2}$/)) {
const [year, month, day] = event_date.split('-').map(num => parseInt(num, 10));
expires_at = new Date(year, month - 1, day);
} else {
expires_at = new Date(event_date);
// If expiration is not required, expires_at will be null (never expires)
// If event_date is not provided, use current date as base for expiration
let expires_at = null;
if (fieldRequirements.require_expiration) {
const baseDate = event_date || new Date().toISOString().split('T')[0];
// Parse YYYY-MM-DD format as local date to avoid timezone issues
if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10));
expires_at = new Date(year, month - 1, day);
} else {
expires_at = new Date(baseDate);
}
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
}
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
// Create folder structure
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -280,22 +376,42 @@ router.post('/', adminAuth, requirePermission('events.create'), [
await fs.mkdir(path.join(eventPath, 'collages'), { recursive: true });
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
// Sync header_style / hero_divider_style from color_theme JSON when not
// explicitly provided in the request body (#158).
let effectiveHeaderStyle = header_style;
let effectiveDividerStyle = hero_divider_style;
if (color_theme && (!req.body.header_style || !req.body.hero_divider_style)) {
try {
if (typeof color_theme === 'string' && color_theme.startsWith('{')) {
const parsed = JSON.parse(color_theme);
if (!req.body.header_style && parsed.headerStyle) {
effectiveHeaderStyle = parsed.headerStyle;
}
if (!req.body.hero_divider_style && parsed.heroDividerStyle) {
effectiveDividerStyle = parsed.heroDividerStyle;
}
}
} catch (_) {
// color_theme is not JSON nothing to extract
}
}
// Insert into database
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
event_date,
event_date: event_date || null,
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
host_name: customerName,
host_email: customerEmail,
admin_email,
host_name: customerName || null,
host_email: customerEmail || null,
admin_email: admin_email || null,
password_hash,
welcome_message,
color_theme,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at.toISOString(),
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString(),
created_by: req.admin.id,
allow_user_uploads,
@@ -305,7 +421,20 @@ router.post('/', adminAuth, requirePermission('events.create'), [
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
watermark_text,
require_password: formatBoolean(requirePassword),
css_template_id: css_template_id || null
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',
header_style: effectiveHeaderStyle || 'standard',
hero_divider_style: effectiveDividerStyle || 'wave',
hero_image_anchor: hero_image_anchor || 'center',
photo_cap: photo_cap || null,
// 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)
@@ -335,14 +464,12 @@ router.post('/', adminAuth, requirePermission('events.create'), [
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// Queue creation email
// Queue creation email (only if there is a recipient)
// Language detection is handled by email processor
await db('email_queue').insert({
event_id: eventId,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify({
if (customerEmail) {
// Build email data with optional client access info
const emailData = {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
@@ -350,13 +477,28 @@ router.post('/', adminAuth, requirePermission('events.create'), [
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.toISOString(), // Pass ISO string - will be formatted by email processor
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
}),
status: 'pending',
created_at: new Date()
// scheduled_at will use default value
});
};
// 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;
}
await db('email_queue').insert({
event_id: eventId,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify(emailData),
status: 'pending',
created_at: new Date()
// scheduled_at will use default value
});
}
res.json({
id: eventId,
@@ -366,8 +508,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
customer_name: customerName,
customer_email: customerEmail,
require_password: requirePassword,
photo_cap: photo_cap || null,
share_link: shareUrl,
expires_at: expires_at.toISOString(),
expires_at: expires_at ? expires_at.toISOString() : null,
created_at: new Date().toISOString()
});
} catch (error) {
@@ -384,8 +527,9 @@ router.get('/', adminAuth, requirePermission('events.view'), async (req, res) =>
const offset = (page - 1) * limit;
const search = req.query.search || '';
const status = req.query.status || 'all';
const sortBy = req.query.sortBy || 'created_at';
const sortOrder = req.query.sortOrder || 'desc';
const allowedSortBy = ['created_at', 'event_name', 'slug', 'updated_at', 'expires_at', 'capture_date'];
const sortBy = allowedSortBy.includes(req.query.sortBy) ? req.query.sortBy : 'created_at';
const sortOrder = ['asc', 'desc'].includes(req.query.sortOrder) ? req.query.sortOrder : 'desc';
// Build query
let query = db('events');
@@ -537,11 +681,11 @@ router.get('/:id', adminAuth, requirePermission('events.view'), async (req, res)
});
// 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(),
body('expires_at').optional().isISO8601(),
body('expires_at').optional({ nullable: true, checkFalsy: true }).isISO8601(),
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }),
body('allow_user_uploads').optional().isBoolean(),
@@ -573,7 +717,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
body('overlay_protection').optional().isBoolean(),
body('image_quality').optional().isInt({ min: 1, max: 100 }),
body('fragmentation_level').optional().isInt({ min: 1, max: 10 }),
body('password').optional().isString().custom((value, { req }) => {
body('password').optional().isString().custom((value) => {
if (value === undefined || value === null || value === '') {
return true;
}
@@ -582,7 +726,20 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
}
return true;
}),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
// Hero logo settings
body('hero_logo_visible').optional().isBoolean(),
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom']),
// Header style settings (decoupled from layout)
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),
// Client access settings (#172)
body('client_access_enabled').optional().isBoolean(),
body('client_password').optional().isString(),
body('regenerate_client_token').optional().isBoolean()
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -661,6 +818,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,
@@ -694,6 +870,43 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
}
// Enforce expires_at requirement based on app settings
if (Object.prototype.hasOwnProperty.call(updates, 'expires_at')) {
if (!updates.expires_at) {
const fieldReqs = await getEventFieldRequirements();
if (fieldReqs.require_expiration) {
return res.status(400).json({ error: 'Expiration date is required.' });
}
updates.expires_at = null;
}
}
// Format hero logo settings if provided
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
}
// Sync header_style / hero_divider_style from color_theme JSON when not
// explicitly provided in the request body (#158). This ensures the
// database columns stay in sync even if the frontend only sends the
// serialised theme object.
if (updates.color_theme && !Object.prototype.hasOwnProperty.call(updates, 'header_style')) {
try {
const themeStr = typeof updates.color_theme === 'string' ? updates.color_theme : '';
if (themeStr.startsWith('{')) {
const parsed = JSON.parse(themeStr);
if (parsed.headerStyle) {
updates.header_style = parsed.headerStyle;
}
if (parsed.heroDividerStyle && !Object.prototype.hasOwnProperty.call(updates, 'hero_divider_style')) {
updates.hero_divider_style = parsed.heroDividerStyle;
}
}
} catch (_) {
// color_theme is not JSON (e.g. preset name) nothing to extract
}
}
// Update event
await db('events')
.where('id', id)
@@ -714,7 +927,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;
@@ -759,7 +972,7 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req,
if (event.archive_path) {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const archivePath = path.join(storagePath, event.archive_path);
try {
const fsPromises = require('fs').promises;
await fsPromises.unlink(archivePath);
@@ -768,6 +981,16 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req,
// Don't fail the transaction if file deletion fails
}
}
// Delete custom event logo if exists
if (event.hero_logo_path) {
try {
const fsPromises = require('fs').promises;
await fsPromises.unlink(event.hero_logo_path);
} catch (err) {
logger.warn('Failed to delete event logo file during event deletion', { path: event.hero_logo_path, error: err.message });
}
}
});
// Log activity (outside transaction)
@@ -795,7 +1018,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;
@@ -835,7 +1058,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;
@@ -902,7 +1125,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;
@@ -927,7 +1150,8 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), as
// For resending creation email, we need the actual password
// First, try to get it from the request body if provided
let galleryPassword = req.body.password;
// Use optional chaining to handle cases where req.body might be undefined
let galleryPassword = req.body?.password;
// If no password provided, we can't decrypt the existing one
// So we'll show a security message
@@ -985,7 +1209,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;
@@ -1094,4 +1318,103 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
}
});
// Upload event custom logo
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, eventLogoUpload.single('logo'), async (req, res) => {
try {
const { id } = req.params;
// Check if event exists
let eventQuery = db('events').where('id', id);
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
if (!req.file) {
return res.status(400).json({ error: 'No logo file provided' });
}
// Delete old logo file if exists
if (event.hero_logo_path) {
try {
await fs.unlink(event.hero_logo_path);
logger.debug('Deleted old event logo file', { path: event.hero_logo_path });
} catch (err) {
logger.warn('Failed to delete old event logo file', { path: event.hero_logo_path, error: err.message });
}
}
const logoUrl = `/uploads/logos/events/${req.file.filename}`;
const logoPath = req.file.path;
await db('events')
.where('id', id)
.update({
hero_logo_url: logoUrl,
hero_logo_path: logoPath
});
await logActivity('event_logo_uploaded',
{ eventName: event.event_name, filename: req.file.filename },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
message: 'Event logo uploaded successfully',
hero_logo_url: logoUrl
});
} catch (error) {
logger.error('Error uploading event logo:', { error: error.message, eventId: req.params.id });
res.status(500).json({ error: 'Failed to upload event logo' });
}
});
// Delete event custom logo
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), requireEventOwnership, async (req, res) => {
try {
const { id } = req.params;
let eventQuery = db('events').where('id', id);
if (req.admin.roleName === 'editor') {
eventQuery = eventQuery.where('created_by', req.admin.id);
}
const event = await eventQuery.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Delete logo file if exists
if (event.hero_logo_path) {
try {
await fs.unlink(event.hero_logo_path);
logger.debug('Deleted event logo file', { path: event.hero_logo_path });
} catch (err) {
logger.warn('Failed to delete event logo file', { path: event.hero_logo_path, error: err.message });
}
}
await db('events')
.where('id', id)
.update({
hero_logo_url: null,
hero_logo_path: null
});
await logActivity('event_logo_removed',
{ eventName: event.event_name },
id,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Event logo removed successfully' });
} catch (error) {
logger.error('Error deleting event logo:', { error: error.message, eventId: req.params.id });
res.status(500).json({ error: 'Failed to delete event logo' });
}
});
module.exports = router;
+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) => {
+155
View File
@@ -0,0 +1,155 @@
const express = require('express');
const router = express.Router();
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const fs = require('fs').promises;
const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver');
// Module-level progress state
let repairProgress = {
isRunning: false,
lastResult: null
};
// Repair photo dimensions (background job)
router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), async (req, res) => {
try {
if (repairProgress.isRunning) {
return res.status(409).json({ error: 'Repair is already running' });
}
const photos = await db('photos')
.join('events', 'photos.event_id', 'events.id')
.where(function () {
this.whereNull('photos.width').orWhereNull('photos.height');
})
.where(function () {
this.where('photos.media_type', '!=', 'video').orWhereNull('photos.media_type');
})
.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 });
}
// Return immediately
res.json({
message: `Started repairing dimensions for ${photos.length} photos`,
count: photos.length
});
// Process in background
repairProgress.isRunning = true;
repairProgress.lastResult = null;
setImmediate(async () => {
let sharp;
try {
sharp = require('sharp');
} catch (err) {
logger.error('Sharp not available for dimension repair:', err.message);
repairProgress.isRunning = false;
repairProgress.lastResult = { success: 0, failed: 0, error: 'Sharp not available' };
return;
}
let successCount = 0;
let errorCount = 0;
for (const photo of photos) {
try {
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;
}
try {
await fs.access(fullPath);
} catch (err) {
logger.warn(`File not found for photo ${photo.id}: ${fullPath}`);
errorCount++;
continue;
}
const metadata = await sharp(fullPath).metadata();
if (metadata.width && metadata.height) {
await db('photos')
.where({ id: photo.id })
.update({
width: metadata.width,
height: metadata.height
});
successCount++;
if (successCount % 50 === 0) {
logger.info(`Dimension repair progress: ${successCount} updated...`);
}
} else {
logger.warn(`Could not extract dimensions for photo ${photo.id}`);
errorCount++;
}
} catch (error) {
logger.error(`Error repairing dimensions for photo ${photo.id}:`, error);
errorCount++;
}
}
repairProgress.isRunning = false;
repairProgress.lastResult = { success: successCount, failed: errorCount };
logger.info(`Dimension repair complete: ${successCount} success, ${errorCount} errors`);
});
} catch (error) {
logger.error('Error starting dimension repair:', error);
res.status(500).json({ error: 'Failed to start dimension repair' });
}
});
// Get dimension repair status
router.get('/repair-dimensions/status', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const totalPhotos = await db('photos')
.where(function () {
this.where('media_type', '!=', 'video').orWhereNull('media_type');
})
.count('id as count')
.first();
const withDimensions = await db('photos')
.where(function () {
this.where('media_type', '!=', 'video').orWhereNull('media_type');
})
.whereNotNull('width')
.whereNotNull('height')
.count('id as count')
.first();
const total = Number(totalPhotos.count);
const withDims = Number(withDimensions.count);
res.json({
total,
withDimensions: withDims,
withoutDimensions: total - withDims,
isRunning: repairProgress.isRunning,
lastResult: repairProgress.lastResult
});
} catch (error) {
logger.error('Error fetching dimension repair status:', error);
res.status(500).json({ error: 'Failed to fetch dimension repair status' });
}
});
module.exports = router;
+4 -4
View File
@@ -71,12 +71,12 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
// Build filtered query
const filterBuilder = new PhotoFilterBuilder(
db('photos')
.leftJoin('categories', 'photos.category_id', 'categories.id')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.select(
'photos.id',
'photos.filename',
'photos.original_filename',
'photos.file_path',
'photos.path',
'photos.average_rating',
'photos.feedback_count',
'photos.like_count',
@@ -84,8 +84,8 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
'photos.comment_count',
'photos.width',
'photos.height',
'photos.created_at',
'categories.name as category_name'
'photos.uploaded_at',
'photo_categories.name as category_name'
),
eventId
);
+202 -67
View File
@@ -5,13 +5,16 @@ const fs = require('fs').promises;
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { requirePermission } = require('../middleware/permissions');
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
const { generateThumbnail, ensureThumbnail, extractCaptureDate } = require('../services/imageProcessor');
const { processUploadedVideo, isVideoMimeType } = require('../services/videoProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
const { getMaxFilesPerUpload, getAllowedMimeTypes } = require('../services/uploadSettings');
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
@@ -46,47 +49,55 @@ const storage = multer.diskStorage({
}
});
const { validateFileType } = require('../utils/fileSecurityUtils');
const { validateFileType, createFileUploadValidator } = require('../utils/fileSecurityUtils');
// Create a multer instance that uses dynamically resolved allowed MIME types.
// The allowed types are fetched from the database once per request (before multer
// processes files) and attached to req.allowedMimeTypes so that the fileFilter
// callback can read them synchronously.
const upload = multer({
storage: storage,
limits: {
fileSize: 10 * 1024 * 1024 * 1024, // 10GB limit per file to support large videos
files: 2000, // Hard safety ceiling; actual limit enforced dynamically
// Set a reasonable field size limit to prevent memory issues
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
// Add part size limits to prevent incomplete uploads
parts: 10000, // Maximum number of parts (fields + files)
headerPairs: 2000 // Maximum number of header key-value pairs
parts: 10000,
headerPairs: 2000
},
fileFilter: (req, file, cb) => {
// Accept images and videos with proper validation
const allowedMimeTypes = [
'image/jpeg', 'image/png', 'image/webp',
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
];
// req.allowedMimeTypes is populated by the middleware that runs before multer
const allowedMimeTypes = req.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp'];
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
return cb(null, true);
} else {
cb(new Error('Only JPEG, PNG, WebP images and MP4, WebM, MOV, AVI videos are allowed'));
cb(new Error('Invalid file type. Check allowed file types in system settings.'));
}
},
// Add abort on limit to stop processing when limits are exceeded
abortOnLimit: true
});
const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
// Middleware to resolve allowed MIME types from settings before multer runs
const resolveAllowedTypes = async (req, res, next) => {
try {
req.allowedMimeTypes = await getAllowedMimeTypes();
} catch (error) {
console.error('Failed to resolve allowed MIME types:', error);
req.allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
}
next();
};
// Create content validator middleware
const validateUploadContent = createFileUploadValidator({
allowedTypes: [
'image/jpeg', 'image/png', 'image/webp',
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
],
maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos
validateContent: true
});
// Dynamic content validator middleware that reads allowed types from req
const validateUploadContent = async (req, res, next) => {
const allowedTypes = req.allowedMimeTypes || ['image/jpeg', 'image/png', 'image/webp'];
const validator = createFileUploadValidator({
allowedTypes,
maxFileSize: 10 * 1024 * 1024 * 1024, // 10GB to support large videos
validateContent: true
});
return validator(req, res, next);
};
// Request timeout middleware for uploads
const uploadTimeout = (timeout = 300000) => { // 5 minutes default
@@ -110,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), 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();
@@ -160,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));
@@ -175,8 +209,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
return res.status(400).json({ error: 'No files uploaded' });
}
// Parse category_id to number if provided
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
// Parse category_id to number if provided (handle string values like 'individual', 'collage')
const rawParsed = category_id ? parseInt(category_id, 10) : NaN;
const parsedCategoryId = !isNaN(rawParsed) ? rawParsed : null;
// Determine photo type and category name
let photoType = 'individual'; // default
@@ -251,16 +286,33 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
const finalPath = path.join(finalDestPath, newFilename);
const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), finalPath);
// Extract capture date from EXIF metadata
let capturedAt = null;
try {
capturedAt = await extractCaptureDate(tempPath);
} catch (exifError) {
// Non-fatal - just log and continue without capture date
console.log(`Could not extract EXIF date for ${file.originalname}`);
}
// Determine media type
const isVideo = isVideoMimeType(file.mimetype);
const mediaType = isVideo ? 'video' : 'image';
// Prepare photo data for batch insert
const photoData = {
event_id: parseInt(eventId),
filename: newFilename,
original_filename: file.originalname, // Preserve original filename for Lightroom export
path: relativePath,
thumbnail_path: null, // Will generate after successful commit
type: photoType,
category_id: parsedCategoryId, // Save the selected category
size_bytes: tempStats.size // Use actual file size from stat
size_bytes: tempStats.size, // Use actual file size from stat
captured_at: capturedAt, // EXIF capture date (if available)
media_type: mediaType,
mime_type: file.mimetype
};
batchPhotos.push(photoData);
@@ -304,20 +356,67 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
throw new Error(`File size mismatch after move: expected ${operation.photoData.size_bytes}, got ${finalStats.size}`);
}
// Generate thumbnail with final path
// Generate thumbnail and extract metadata
const photoId = insertedIds[idx]?.id || insertedIds[idx];
const isVideoFile = isVideoMimeType(operation.photoData.mime_type);
let thumbnailPath = null;
try {
thumbnailPath = await generateThumbnail(operation.finalPath);
// Update the database with thumbnail path
if (thumbnailPath && insertedIds[idx]) {
const photoId = insertedIds[idx]?.id || insertedIds[idx];
await db('photos')
.where({ id: photoId })
.update({ thumbnail_path: thumbnailPath });
if (isVideoFile) {
// Process video: extract metadata and generate thumbnail
const thumbnailDir = path.join(getStoragePath(), 'thumbnails');
await fs.mkdir(thumbnailDir, { recursive: true });
const videoThumbnailPath = path.join(thumbnailDir, `thumb_${operation.filename.replace(/\.[^.]+$/, '.jpg')}`);
const result = await processUploadedVideo(operation.finalPath, videoThumbnailPath);
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
if (photoId && result.metadata) {
await db('photos')
.where({ id: photoId })
.update({
thumbnail_path: thumbnailPath,
duration: result.metadata.duration,
video_codec: result.metadata.videoCodec,
audio_codec: result.metadata.audioCodec,
width: result.metadata.width,
height: result.metadata.height
});
}
} else {
thumbnailPath = await generateThumbnail(operation.finalPath);
// Update the database with thumbnail path and image dimensions
if (photoId) {
const updateData = {};
if (thumbnailPath) updateData.thumbnail_path = thumbnailPath;
try {
const sharp = require('sharp');
const metadata = await sharp(operation.finalPath).metadata();
if (metadata.width && metadata.height) {
updateData.width = metadata.width;
updateData.height = metadata.height;
}
} catch (metadataError) {
console.warn(`Could not extract image dimensions for ${operation.filename}:`, metadataError.message);
}
if (Object.keys(updateData).length > 0) {
await db('photos')
.where({ id: photoId })
.update(updateData);
}
}
}
} catch (thumbError) {
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
console.error(`Thumbnail/metadata processing failed for ${operation.filename}:`, thumbError.message);
}
// Queue watermark generation in background (non-blocking, images only)
if (photoId && !isVideoFile) {
watermarkGeneratorService.generateForPhoto(photoId)
.catch(err => console.warn(`Watermark generation queued failed for photo ${photoId}:`, err.message));
}
// Add to successful uploads
@@ -424,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;
@@ -461,7 +560,12 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
}
}
}
// Delete pre-generated watermark if exists
if (photo.watermark_path) {
await watermarkGeneratorService.deleteForPhoto(photo.id);
}
// Remove from database
await db('photos').where({ id: photoId }).delete();
@@ -481,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')
@@ -498,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') {
@@ -537,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;
@@ -582,8 +693,13 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
}
}
}
// Delete pre-generated watermark
if (photo.watermark_path) {
await watermarkGeneratorService.deleteForPhoto(photo.id);
}
}
// Delete from database
await db('photos')
.whereIn('id', photoIds)
@@ -605,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;
@@ -626,9 +742,14 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
}
// Prepare update data
const updateData = {
updated_at: new Date()
};
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')
@@ -663,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;
@@ -695,23 +816,31 @@ 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', order = 'desc' } = req.query;
const { category_id, type, search, sort = 'date' } = req.query;
const order = ['asc', 'desc'].includes(req.query.order) ? req.query.order : 'desc';
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 type (individual/collage) - category_id maps to type
if (category_id !== undefined) {
if (category_id === '' || category_id === '0') {
// For backwards compatibility, empty category means no filter
// Don't filter anything
} else if (category_id === 'individual' || category_id === 'collage') {
// Filter by category_id
if (category_id !== undefined && category_id !== '' && category_id !== '0') {
if (category_id === 'individual' || category_id === 'collage') {
// Legacy type-based filtering
query = query.where({ 'photos.type': category_id });
} else if (category_id === 'uncategorized') {
// Filter for photos with no category assigned
query = query.whereNull('photos.category_id');
} else {
// Numeric category ID from photo_categories table
const numericCategoryId = parseInt(category_id, 10);
if (!isNaN(numericCategoryId)) {
query = query.where({ 'photos.category_id': numericCategoryId });
}
}
}
@@ -755,6 +884,7 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
photos: photos.map(photo => ({
id: photo.id,
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}`,
// Always expose a thumbnail URL; backend will generate on demand if missing
@@ -763,6 +893,11 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
category_id: photo.category_id || photo.type,
category_name: photo.pc_name || (photo.type === 'individual' ? 'Individual Photos' : 'Collages'),
category_slug: photo.pc_slug || photo.type,
media_type: photo.media_type || 'image',
mime_type: photo.mime_type || null,
width: photo.width || null,
height: photo.height || null,
duration: photo.duration || null,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at,
// Feedback data
@@ -780,7 +915,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;
@@ -817,7 +952,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;
@@ -857,7 +992,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;
@@ -883,7 +1018,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;
@@ -921,7 +1056,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;
@@ -942,7 +1077,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;
@@ -984,7 +1119,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;
@@ -1002,7 +1137,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;
+176 -22
View File
@@ -23,6 +23,8 @@ const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
const { resetSecurityConfigCache } = require('../utils/authSecurity');
const router = express.Router();
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
@@ -100,19 +102,31 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
if (setting.setting_value) {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
// Check for null/undefined explicitly to handle boolean false and 0 values
// PostgreSQL json column returns parsed values (false as boolean, not string)
if (setting.setting_value !== null && setting.setting_value !== undefined) {
// If the value is already parsed (from json column), use it directly
if (typeof setting.setting_value !== 'string') {
settingsObject[setting.setting_key] = setting.setting_value;
} else {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
}
}
} else {
settingsObject[setting.setting_key] = null;
}
});
// Mask sensitive secrets before sending to client
if (settingsObject.security_recaptcha_secret_key) {
settingsObject.security_recaptcha_secret_key = '••••••••';
}
res.json(settingsObject);
} catch (error) {
console.error('Settings fetch error:', error);
@@ -127,23 +141,35 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
const settings = await db('app_settings')
.where('setting_type', type)
.select('*');
// Convert to object format
const settingsObject = {};
settings.forEach(setting => {
if (setting.setting_value) {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
// Check for null/undefined explicitly to handle boolean false and 0 values
// PostgreSQL json column returns parsed values (false as boolean, not string)
if (setting.setting_value !== null && setting.setting_value !== undefined) {
// If the value is already parsed (from json column), use it directly
if (typeof setting.setting_value !== 'string') {
settingsObject[setting.setting_key] = setting.setting_value;
} else {
try {
// Try to parse as JSON first
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
settingsObject[setting.setting_key] = setting.setting_value;
}
}
} else {
settingsObject[setting.setting_key] = null;
}
});
// Mask sensitive secrets before sending to client
if (settingsObject.security_recaptcha_secret_key) {
settingsObject.security_recaptcha_secret_key = '••••••••';
}
res.json(settingsObject);
} catch (error) {
console.error('Settings fetch error:', error);
@@ -196,6 +222,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
hide_powered_by
} = req.body;
// Get current watermark settings hash for change detection
const oldSettingsHash = await watermarkService.getSettingsHash();
const brandingSettings = {
company_name,
company_tagline,
@@ -306,7 +335,40 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
clearPublicSiteCache();
res.json({ message: 'Branding settings updated successfully' });
// Check if watermark settings changed and trigger regeneration
const newSettingsHash = await watermarkService.getSettingsHash();
let watermarkRegenerationStarted = false;
if (oldSettingsHash !== newSettingsHash) {
// Clear watermark cache
watermarkService.clearCache();
// Check if watermarking is now enabled or settings changed
const currentSettings = await watermarkService.getWatermarkSettings();
if (currentSettings && currentSettings.enabled) {
// Start background regeneration of all watermarks
console.log('Watermark settings changed, starting background regeneration');
watermarkGeneratorService.regenerateAll()
.then(result => {
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
})
.catch(err => {
console.error('Watermark regeneration failed:', err);
});
watermarkRegenerationStarted = true;
} else {
// Watermarking was disabled, clear all pre-generated watermarks
console.log('Watermarking disabled, clearing pre-generated watermarks');
watermarkGeneratorService.clearAllWatermarks()
.catch(err => console.error('Failed to clear watermarks:', err));
}
}
res.json({
message: 'Branding settings updated successfully',
watermarkRegenerationStarted
});
} catch (error) {
console.error('Branding update error:', error);
res.status(500).json({ error: 'Failed to update branding settings' });
@@ -392,11 +454,21 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
.first();
if (oldWatermarkLogoSetting && oldWatermarkLogoSetting.setting_value) {
const oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value);
let oldPath;
try {
await fs.unlink(oldPath);
} catch (error) {
console.error('Failed to delete old watermark logo:', error);
// Try to parse as JSON first (for JSON-stringified paths)
oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value);
} catch (e) {
// If it's not valid JSON, use the raw value
oldPath = oldWatermarkLogoSetting.setting_value;
}
if (oldPath && typeof oldPath === 'string') {
try {
await fs.unlink(oldPath);
} catch (error) {
console.error('Failed to delete old watermark logo:', error);
}
}
}
@@ -421,19 +493,37 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
await db('app_settings')
.insert({
setting_key: 'branding_watermark_logo_url',
setting_value: publicPath,
setting_value: JSON.stringify(publicPath),
setting_type: 'branding',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: publicPath,
setting_value: JSON.stringify(publicPath),
updated_at: new Date()
});
res.json({
// Trigger watermark regeneration since the logo changed
watermarkService.clearCache();
const currentSettings = await watermarkService.getWatermarkSettings();
let watermarkRegenerationStarted = false;
if (currentSettings && currentSettings.enabled) {
console.log('Watermark logo changed, starting background regeneration');
watermarkGeneratorService.regenerateAll()
.then(result => {
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
})
.catch(err => {
console.error('Watermark regeneration failed:', err);
});
watermarkRegenerationStarted = true;
}
res.json({
message: 'Watermark logo uploaded successfully',
watermarkLogoUrl: publicPath
watermarkLogoUrl: publicPath,
watermarkRegenerationStarted
});
} catch (error) {
console.error('Watermark logo upload error:', error);
@@ -653,6 +743,70 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
}
});
// Update SEO settings
router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const settings = req.body;
// Validate seo_blocked_ai_agents is an array of strings
if (settings.seo_blocked_ai_agents !== undefined) {
if (!Array.isArray(settings.seo_blocked_ai_agents) ||
!settings.seo_blocked_ai_agents.every(a => typeof a === 'string')) {
return res.status(400).json({ error: 'seo_blocked_ai_agents must be an array of strings' });
}
}
// Validate seo_custom_rules structure
if (settings.seo_custom_rules !== undefined) {
if (!Array.isArray(settings.seo_custom_rules)) {
return res.status(400).json({ error: 'seo_custom_rules must be an array' });
}
for (const rule of settings.seo_custom_rules) {
if (!rule.userAgent || typeof rule.userAgent !== 'string') {
return res.status(400).json({ error: 'Each custom rule must have a userAgent string' });
}
if (!Array.isArray(rule.disallow) || !rule.disallow.every(d => typeof d === 'string')) {
return res.status(400).json({ error: 'Each custom rule must have a disallow array of strings' });
}
}
}
// Update or insert each setting
for (const [key, value] of Object.entries(settings)) {
await db('app_settings')
.insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'seo',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: JSON.stringify(value),
updated_at: new Date()
});
}
// Clear robots.txt cache
const { clearRobotsTxtCache } = require('../services/robotsTxtService');
clearRobotsTxtCache();
// Log activity
await db('activity_logs').insert({
activity_type: 'seo_settings_updated',
actor_type: 'admin',
actor_id: req.admin.id,
actor_name: req.admin.username,
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
});
res.json({ message: 'SEO settings updated successfully' });
} catch (error) {
console.error('SEO settings update error:', error);
res.status(500).json({ error: 'Failed to update SEO settings' });
}
});
// Get storage info
router.get('/storage/info', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
+143 -2
View File
@@ -7,6 +7,13 @@ const path = require('path');
const os = require('os');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckService');
const { detectEnvironment, generateUpdateInstructions } = require('../services/environmentService');
const {
checkAndNotifyUpdates,
sendUpdateNotificationNow,
getUpdateNotificationSettings
} = require('../services/updateNotificationService');
const router = express.Router();
// Get system version
@@ -22,12 +29,15 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req
} catch (err) {
console.error('Could not read package.json:', err);
}
const channel = getCurrentChannel(backendVersion);
res.json({
backend: backendVersion,
frontend: '1.0.0', // This will be set by frontend
node: process.version,
environment: process.env.NODE_ENV || 'production'
environment: process.env.NODE_ENV || 'production',
channel: channel
});
} catch (error) {
console.error('Error fetching version:', error);
@@ -35,6 +45,73 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req
}
});
// Check for updates
router.get('/updates', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Check if update checking is enabled
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
if (!updateCheckEnabled) {
return res.json({
enabled: false,
message: 'Update checking is disabled'
});
}
const forceRefresh = req.query.refresh === 'true';
const updateInfo = await checkForUpdates(forceRefresh);
res.json({
enabled: true,
...updateInfo
});
} catch (error) {
logger.error('Error checking for updates:', error);
res.status(500).json({ error: 'Failed to check for updates' });
}
});
// Get update instructions for current environment
router.get('/updates/instructions', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
// Check if update checking is enabled
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
if (!updateCheckEnabled) {
return res.json({
enabled: false,
message: 'Update checking is disabled'
});
}
const env = await detectEnvironment();
const updateInfo = await checkForUpdates();
if (!updateInfo.updateAvailable) {
return res.json({
updateAvailable: false,
currentVersion: updateInfo.current,
message: 'You are running the latest version'
});
}
const instructions = generateUpdateInstructions(env, updateInfo.latest.forChannel);
res.json({
updateAvailable: true,
currentVersion: updateInfo.current,
targetVersion: updateInfo.latest.forChannel,
channel: updateInfo.channel,
environment: env,
instructions,
releaseNotesUrl: `https://github.com/the-luap/picpeak/releases/tag/v${updateInfo.latest.forChannel}`
});
} catch (error) {
logger.error('Error generating update instructions:', error);
res.status(500).json({ error: 'Failed to generate update instructions' });
}
});
// Get comprehensive system status
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
@@ -232,4 +309,68 @@ router.get('/database', adminAuth, requirePermission('settings.view'), async (re
}
});
// Get update notification settings
router.get('/updates/notifications', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const settings = await getUpdateNotificationSettings();
res.json(settings);
} catch (error) {
logger.error('Error fetching update notification settings:', error);
res.status(500).json({ error: 'Failed to fetch update notification settings' });
}
});
// Update notification settings
router.put('/updates/notifications', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const { enabled, recipients } = req.body;
if (typeof enabled !== 'undefined') {
await db('app_settings')
.where('setting_key', 'update_email_notifications_enabled')
.update({
setting_value: JSON.stringify(enabled === true),
updated_at: db.fn.now()
});
}
if (typeof recipients !== 'undefined') {
await db('app_settings')
.where('setting_key', 'update_email_recipients')
.update({
setting_value: JSON.stringify(recipients || ''),
updated_at: db.fn.now()
});
}
const updatedSettings = await getUpdateNotificationSettings();
res.json({ success: true, settings: updatedSettings });
} catch (error) {
logger.error('Error updating notification settings:', error);
res.status(500).json({ error: 'Failed to update notification settings' });
}
});
// Manually trigger update notification email
router.post('/updates/notifications/send', adminAuth, requirePermission('settings.edit'), async (req, res) => {
try {
const result = await sendUpdateNotificationNow();
res.json(result);
} catch (error) {
logger.error('Error sending update notification:', error);
res.status(500).json({ error: 'Failed to send update notification' });
}
});
// Check and send update notifications (called on admin login or periodically)
router.post('/updates/notifications/check', adminAuth, requirePermission('settings.view'), async (req, res) => {
try {
const result = await checkAndNotifyUpdates();
res.json(result);
} catch (error) {
logger.error('Error checking for update notifications:', error);
res.status(500).json({ error: 'Failed to check for update notifications' });
}
});
module.exports = router;
+21 -15
View File
@@ -10,27 +10,33 @@ const logger = require('../utils/logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Parse JSON-encoded setting values
function parseSettingValue(value) {
if (value === null || value === undefined) return null;
try { return JSON.parse(value); } catch (e) { return value; }
}
// Get thumbnail settings
router.get('/settings', adminAuth, requirePermission('photos.view'), async (req, res) => {
try {
const settings = await db('app_settings')
.whereIn('key', [
.whereIn('setting_key', [
'thumbnail_width',
'thumbnail_height',
'thumbnail_fit',
'thumbnail_quality',
'thumbnail_format'
])
.select('key', 'value', 'description');
.select('setting_key', 'setting_value');
const settingsMap = {};
settings.forEach(s => {
settingsMap[s.key] = {
value: s.value,
description: s.description
const parsed = parseSettingValue(s.setting_value);
settingsMap[s.setting_key] = {
value: String(parsed ?? '')
};
});
res.json({
settings: settingsMap,
fitOptions: ['cover', 'contain', 'fill', 'inside', 'outside'],
@@ -66,17 +72,17 @@ router.put('/settings', adminAuth, requirePermission('photos.edit'), async (req,
// Update settings
const updates = [];
if (width) updates.push({ key: 'thumbnail_width', value: width.toString() });
if (height) updates.push({ key: 'thumbnail_height', value: height.toString() });
if (fit) updates.push({ key: 'thumbnail_fit', value: fit });
if (quality) updates.push({ key: 'thumbnail_quality', value: quality.toString() });
if (format) updates.push({ key: 'thumbnail_format', value: format });
if (width) updates.push({ setting_key: 'thumbnail_width', setting_value: width });
if (height) updates.push({ setting_key: 'thumbnail_height', setting_value: height });
if (fit) updates.push({ setting_key: 'thumbnail_fit', setting_value: JSON.stringify(fit) });
if (quality) updates.push({ setting_key: 'thumbnail_quality', setting_value: quality });
if (format) updates.push({ setting_key: 'thumbnail_format', setting_value: JSON.stringify(format) });
for (const update of updates) {
await db('app_settings')
.where('key', update.key)
.where('setting_key', update.setting_key)
.update({
value: update.value,
setting_value: update.setting_value,
updated_at: db.fn.now()
});
}
+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,
+8 -1
View File
@@ -11,6 +11,7 @@ const path = require('path');
const router = express.Router();
const { buildShareLinkVariants } = require('../services/shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('../services/eventTypeService');
// Use parseStringInput from shared parsers for customer data extraction
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
@@ -55,7 +56,13 @@ const hasCustomerContactColumns = async () => {
// Create new event
router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_type').notEmpty().trim().custom(async (value) => {
const isValid = await eventTypeService.isValidEventType(value);
if (!isValid) {
throw new Error('Invalid event type');
}
return true;
}),
body('event_name').notEmpty(),
body('event_date').isDate(),
body('customer_name').notEmpty().trim(),
+413 -63
View File
@@ -5,6 +5,7 @@ const archiver = require('archiver');
const path = require('path');
const router = express.Router();
const watermarkService = require('../services/watermarkService');
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
@@ -12,9 +13,10 @@ const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { handleAsync } = require('../utils/routeHelpers');
const { NotFoundError } = require('../utils/errors');
const { ensureThumbnail, ensureHeroImage } = require('../services/imageProcessor');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Check for slug redirect (for renamed events)
async function checkSlugRedirect(slug) {
@@ -106,13 +108,21 @@ router.get('/:slug/info', async (req, res) => {
'share_link',
'share_token',
'allow_downloads',
'allow_user_uploads',
'disable_right_click',
'watermark_downloads',
'watermark_text',
'require_password',
'color_theme',
'enable_devtools_protection',
'use_canvas_rendering'
'use_canvas_rendering',
'hero_logo_visible',
'hero_logo_size',
'hero_logo_position',
'hero_logo_url',
'header_style',
'hero_divider_style',
'hero_image_anchor'
)
.first();
@@ -150,15 +160,23 @@ router.get('/:slug/info', async (req, res) => {
event_date: event.event_date,
expires_at: event.expires_at,
is_active: event.is_active,
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
is_expired: !event.is_active || (event.expires_at && new Date(event.expires_at) < new Date()),
requires_password: requiresPassword,
color_theme: event.color_theme,
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
allow_user_uploads: event.allow_user_uploads === true || event.allow_user_uploads === 1 || event.allow_user_uploads === '1',
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
watermark_text: event.watermark_text,
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1'
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0',
hero_logo_size: event.hero_logo_size || 'medium',
hero_logo_position: event.hero_logo_position || 'top',
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'
});
} catch (error) {
console.error('Error fetching gallery info:', error);
@@ -169,14 +187,42 @@ router.get('/:slug/info', async (req, res) => {
// Get all photos
router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
try {
// Get filter parameters from query
const { filter, guest_id } = req.query;
// First get all photos
let photos = await db('photos')
// Get filter and sort parameters from query
const { filter, guest_id, sort = 'upload_date', order = 'desc' } = req.query;
// Get watermark settings to generate cache-busting version for URLs
const watermarkSettings = await watermarkService.getWatermarkSettings();
const wmVersion = watermarkSettings?.enabled
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '';
// 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.*')
.orderBy('photos.uploaded_at', 'desc');
.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
photosQuery = photosQuery.orderByRaw('COALESCE(photos.captured_at, photos.uploaded_at) ' + sortOrder);
} else if (sort === 'filename') {
photosQuery = photosQuery.orderBy('photos.filename', sortOrder);
} else {
// Default: sort by upload date
photosQuery = photosQuery.orderBy('photos.uploaded_at', sortOrder);
}
// Execute the query
let photos = await photosQuery;
// Apply filtering if requested (supports global stats + per-guest interactions)
if (filter) {
@@ -272,20 +318,36 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
commentMap[c.photo_id] = parseInt(c.comment_count);
});
// Get distinct photo types for this event
const categoryResults = await db('photos')
// Get actual categories used by photos in this event
// This includes both global categories and event-specific ones
const usedCategoryIds = await db('photos')
.where('event_id', req.event.id)
.select('type')
.distinct('type')
.orderBy('type', 'asc');
// Convert types to category-like objects
const categories = categoryResults.map(result => ({
id: result.type,
name: result.type === 'individual' ? 'Individual Photos' : 'Collages',
slug: result.type,
is_global: false
}));
.whereNotNull('category_id')
.distinct('category_id')
.pluck('category_id');
// Fetch category details from photo_categories table
let categories = [];
if (usedCategoryIds.length > 0) {
const categoryDetails = await db('photo_categories')
.whereIn('id', usedCategoryIds)
.select('id', 'name', 'slug', 'is_global', 'hero_photo_id')
.orderBy('name', 'asc');
categories = categoryDetails.map(cat => ({
id: cat.id,
name: cat.name,
slug: cat.slug,
is_global: cat.is_global,
hero_photo_id: cat.hero_photo_id || null
}));
}
// Build a map for quick category lookup
const categoryMap = {};
categories.forEach(cat => {
categoryMap[cat.id] = cat;
});
// Log view
await db('access_logs').insert({
@@ -316,41 +378,64 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
expires_at: req.event.expires_at,
hero_photo_id: req.event.hero_photo_id,
allow_downloads: req.event.allow_downloads !== false,
allow_user_uploads: req.event.allow_user_uploads === true,
disable_right_click: req.event.disable_right_click === true,
watermark_downloads: req.event.watermark_downloads === true,
watermark_text: req.event.watermark_text,
enable_devtools_protection: req.event.enable_devtools_protection === true,
use_canvas_rendering: req.event.use_canvas_rendering === true,
hero_logo_visible: req.event.hero_logo_visible !== false && req.event.hero_logo_visible !== 0 && req.event.hero_logo_visible !== '0',
hero_logo_size: req.event.hero_logo_size || 'medium',
hero_logo_position: req.event.hero_logo_position || 'top',
hero_logo_url: req.event.hero_logo_url || null,
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',
...protectionSettings
},
categories: categories,
photos: photos.map(photo => {
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
const photoUrl = useJwtUrl ?
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
// Add watermark version to URLs for cache busting when settings change
const wmQuery = wmVersion ? `?${wmVersion}` : '';
const photoUrl = useJwtUrl ?
`/api/gallery/${req.params.slug}/photo/${photo.id}${wmQuery}` :
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
return {
id: photo.id,
filename: photo.filename,
url: photoUrl,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null,
// Hero-optimized image URL (1920x1080) for full-width hero sections
hero_url: `/api/gallery/${req.params.slug}/hero/${photo.id}${wmQuery}`,
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type,
category_id: photo.type,
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
category_slug: photo.type,
category_id: photo.category_id || null,
category_name: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].name : null,
category_slug: photo.category_id && categoryMap[photo.category_id] ? categoryMap[photo.category_id].slug : null,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at,
// Image dimensions for layout calculations
width: photo.width || null,
height: photo.height || null,
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
requires_token: !useJwtUrl,
// 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
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
favorite_count: photo.favorite_count || 0,
// Visibility (only included for clients)
...(isClient ? { visibility: photo.visibility || 'visible' } : {})
};
})
});
@@ -360,24 +445,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);
@@ -676,11 +828,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/'));
@@ -698,8 +854,34 @@ router.get('/:slug/photo/:photoId',
// Resolve the absolute file path for this photo, supporting both managed and external reference modes
const { resolvePhotoFilePath } = require('../services/photoResolver');
const filePath = resolvePhotoFilePath(req.event, photo);
const fs = require('fs');
let filePath;
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
photoPath: photo.path,
photoFilename: photo.filename
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Verify file exists before attempting to serve
if (!fs.existsSync(filePath)) {
logger.error('Photo file does not exist at resolved path', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
resolvedPath: filePath,
photoPath: photo.path
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Log access - temporarily disabled for debugging
// await secureImageService.logImageAccess(
@@ -711,14 +893,13 @@ router.get('/:slug/photo/:photoId',
// Handle video streaming with range requests
if (isVideo) {
const fs = require('fs');
const stat = fs.statSync(filePath);
const fileSize = stat.size;
const range = req.headers.range;
if (range) {
// Parse range header
const parts = range.replace(/bytes=/, "").split("-");
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunksize = (end - start) + 1;
@@ -753,13 +934,52 @@ router.get('/:slug/photo/:photoId',
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
// Generate ETag based on photo id, modification time, and watermark settings
// This ensures cache invalidation when watermark settings change
const stat = fs.statSync(filePath);
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
const etag = `"${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
// Check if client has valid cached version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark and send
// Try to serve pre-generated watermarked file for instant loading
if (photo.watermark_path) {
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
try {
// Check if pre-generated watermark file exists
if (fs.existsSync(watermarkFilePath)) {
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
return res.sendFile(watermarkFilePath);
}
} catch (err) {
// File doesn't exist or error, fall through to on-the-fly generation
logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`);
}
}
// Fallback: Apply watermark on-the-fly (slower, but ensures image is served)
// Also queue regeneration for next time
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
// Queue watermark generation in background for next request
watermarkGeneratorService.generateForPhoto(photo.id)
.catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message));
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
'ETag': etag,
'X-Protection-Level': 'basic'
});
@@ -768,6 +988,7 @@ router.get('/:slug/photo/:photoId',
// Send original file with basic protection headers
res.set({
'Cache-Control': 'private, max-age=1800',
'ETag': etag,
'X-Protection-Level': 'basic'
});
// Ensure absolute path for res.sendFile
@@ -787,30 +1008,35 @@ router.get('/:slug/photo/:photoId',
);
// Serve thumbnail
router.get('/:slug/thumbnail/:photoId',
verifyGalleryAccess,
router.get('/:slug/thumbnail/:photoId',
verifyGalleryAccess,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo || !photo.thumbnail_path) {
return res.status(404).json({ error: 'Thumbnail not found' });
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
// Check if file exists
const fs = require('fs').promises;
try {
await fs.access(thumbPath);
} catch (error) {
return res.status(404).json({ error: 'Thumbnail file 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);
if (!thumbnailPath) {
logger.error(`Failed to generate thumbnail for photo ${photoId}`);
return res.status(404).json({ error: 'Thumbnail generation failed' });
}
const thumbPath = path.join(getStoragePath(), thumbnailPath);
// Log thumbnail access
await secureImageService.logImageAccess(
photoId,
@@ -818,18 +1044,41 @@ router.get('/:slug/thumbnail/:photoId',
req.clientInfo,
'thumbnail'
);
// Check if watermarks are enabled and apply to thumbnail
const watermarkSettings = await watermarkService.getWatermarkSettings();
// Generate ETag based on photo id, thumbnail modification time, and watermark settings
const fs = require('fs');
const stat = fs.statSync(thumbPath);
const watermarkHash = watermarkSettings?.enabled
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
: '-nowm';
const etag = `"thumb-${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
// Check if client has valid cached version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
// Set appropriate headers with enhanced security
res.set({
'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Reduced cache time
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
'X-Protected-Thumbnail': 'true'
'X-Protected-Thumbnail': 'true',
'ETag': etag
});
// Send file
res.sendFile(path.resolve(thumbPath));
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark to thumbnail
const watermarkedBuffer = await watermarkService.applyWatermark(thumbPath, watermarkSettings);
res.send(watermarkedBuffer);
} else {
// Send file without watermark
res.sendFile(path.resolve(thumbPath));
}
} catch (error) {
logger.error('Error serving thumbnail:', {
error: error.message,
@@ -841,6 +1090,97 @@ router.get('/:slug/thumbnail/:photoId',
}
);
// Serve hero-optimized image (1920x1080 for full-width hero sections)
router.get('/:slug/hero/:photoId',
verifyGalleryAccess,
async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// 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) {
// For videos, redirect to the regular photo endpoint
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
}
// Ensure hero image exists and is valid, regenerate if needed
const heroPath = await ensureHeroImage(photo);
if (!heroPath) {
// If hero generation fails, fall back to original photo
logger.warn(`Failed to generate hero image for photo ${photoId}, falling back to original`);
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
}
const heroFullPath = path.join(getStoragePath(), heroPath);
const fs = require('fs');
// Verify file exists before attempting to serve
if (!fs.existsSync(heroFullPath)) {
logger.error('Hero image file does not exist at resolved path', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
resolvedPath: heroFullPath
});
return res.redirect(`/api/gallery/${req.params.slug}/photo/${photoId}`);
}
// Get file stats for ETag
const stat = fs.statSync(heroFullPath);
const etag = `"hero-${photoId}-${stat.mtime.getTime()}"`;
// Check if client has valid cached version
if (req.headers['if-none-match'] === etag) {
return res.status(304).end();
}
// Check if watermarks should be applied
const watermarkSettings = await watermarkService.getWatermarkSettings();
res.set({
'Content-Type': 'image/jpeg',
'Cache-Control': 'private, max-age=3600', // Cache for 1 hour
'Cross-Origin-Resource-Policy': 'cross-origin',
'X-Content-Type-Options': 'nosniff',
'X-Hero-Image': 'true',
'ETag': etag
});
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark to hero image
const watermarkedBuffer = await watermarkService.applyWatermark(heroFullPath, watermarkSettings);
res.send(watermarkedBuffer);
} else {
// Send hero image without watermark
res.sendFile(path.resolve(heroFullPath));
}
} catch (error) {
logger.error('Error serving hero image:', {
error: error.message,
photoId: req.params.photoId,
eventId: req.event?.id
});
// Fall back to original photo on any error
res.redirect(`/api/gallery/${req.params.slug}/photo/${req.params.photoId}`);
}
}
);
// Get feedback settings for gallery
router.get('/:slug/feedback-settings', verifyGalleryAccess, async (req, res) => {
try {
@@ -926,6 +1266,17 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
// Import multer and photo processing
const multer = require('multer');
const { getAllowedMimeTypes } = require('../services/uploadSettings');
const { validateFileType } = require('../utils/fileSecurityUtils');
// Resolve allowed MIME types from settings
let allowedMimeTypes;
try {
allowedMimeTypes = await getAllowedMimeTypes();
} catch {
allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
}
const upload = multer({
dest: tempUploadDir,
limits: {
@@ -933,8 +1284,7 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
files: 10 // Max 10 files at once
},
fileFilter: (req, file, cb) => {
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp'];
if (allowedTypes.includes(file.mimetype)) {
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
cb(null, true);
} else {
cb(new Error('Invalid file type'));
+1 -1
View File
@@ -204,7 +204,7 @@ router.post('/:slug/photos/:photoId/feedback',
guest_name: req.body.guest_name,
guest_email: req.body.guest_email,
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
};
+36 -24
View File
@@ -90,38 +90,50 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
}, 'view');
// Get protection settings from event
const eventProtectionLevel = req.event.protection_level || protectionLevel;
const protectionSettings = {
protectionLevel: req.event.protection_level || protectionLevel,
protectionLevel: eventProtectionLevel,
quality: req.event.image_quality || 85,
addFingerprint: req.event.add_fingerprint !== false,
fragmentImage: protectionLevel === 'maximum'
fragmentImage: eventProtectionLevel === 'maximum'
};
// Build full path to photo
const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
// Process image with protection
const processedImage = await secureImageService.processProtectedImage(photoPath, protectionSettings);
// Apply watermark if enabled
// For basic/standard protection without special features, serve original file
// This avoids unnecessary recompression
const needsProcessing = eventProtectionLevel === 'enhanced' ||
eventProtectionLevel === 'maximum' ||
protectionSettings.addFingerprint;
let finalImage;
if (processedImage.type === 'fragmented') {
// Return fragmented image data for canvas reconstruction
return res.json({
type: 'fragmented',
fragments: processedImage.fragments.map(f => ({
index: f.index,
row: f.row,
col: f.col,
data: f.buffer.toString('base64'),
position: f.position
})),
dimensions: processedImage.originalDimensions,
fragmentDimensions: processedImage.fragmentDimensions
});
if (!needsProcessing) {
// Serve original file without processing
const fs = require('fs').promises;
finalImage = await fs.readFile(photoPath);
} else {
const watermarkSettings = await watermarkService.getWatermarkSettings();
finalImage = await watermarkService.applyWatermark(photoPath, watermarkSettings);
// Process image with protection measures
const processedImage = await secureImageService.processProtectedImage(photoPath, protectionSettings);
if (processedImage.type === 'fragmented') {
// Return fragmented image data for canvas reconstruction
return res.json({
type: 'fragmented',
fragments: processedImage.fragments.map(f => ({
index: f.index,
row: f.row,
col: f.col,
data: f.buffer.toString('base64'),
position: f.position
})),
dimensions: processedImage.originalDimensions,
fragmentDimensions: processedImage.fragmentDimensions
});
}
finalImage = processedImage;
}
// Set security headers
+11 -2
View File
@@ -12,7 +12,8 @@ router.get('/', async (req, res) => {
.where(function() {
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics', 'boolean'])
.orWhere('setting_key', 'like', 'analytics_%')
.orWhere('setting_key', 'like', 'event_require_%');
.orWhere('setting_key', 'like', 'event_require_%')
.orWhereIn('setting_key', ['seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai']);
})
.select('setting_key', 'setting_value');
});
@@ -74,7 +75,15 @@ router.get('/', async (req, res) => {
// Event field requirements
event_require_customer_name: settingsObject.event_require_customer_name !== false,
event_require_customer_email: settingsObject.event_require_customer_email !== false,
event_require_admin_email: settingsObject.event_require_admin_email !== false
event_require_admin_email: settingsObject.event_require_admin_email !== false,
event_require_event_date: settingsObject.event_require_event_date !== false,
event_require_expiration: settingsObject.event_require_expiration !== false,
// Upload settings (safe to expose - needed for client-side validation)
allowed_file_types: settingsObject.general_allowed_file_types || 'jpg,jpeg,png,webp',
// SEO meta tag flags (safe to expose - these are intended for crawlers)
seo_meta_noindex: settingsObject.seo_meta_noindex === true,
seo_meta_nofollow: settingsObject.seo_meta_nofollow === true,
seo_meta_noai: settingsObject.seo_meta_noai === true
};
res.json(publicSettings);
+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 = {
+47 -14
View File
@@ -437,29 +437,63 @@ async function performLocalBackup(config, files) {
};
}
function buildRsyncCommand(config) {
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');
}
const options = ['-avz', '--delete', '--stats'];
// 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) {
options.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 => options.push(`--exclude="${pattern}"`));
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}`;
return `rsync ${options.join(' ')} "${source}" "${destination}"`;
args.push(source, destination);
return args;
}
function parseRsyncStats(output) {
@@ -479,9 +513,9 @@ function parseRsyncStats(output) {
}
async function performRsyncBackup(config, files) {
const command = buildRsyncCommand(config);
const execAsync = getExecAsync();
const { stdout } = await execAsync(command);
const { spawnAsync } = require('../utils/safeExec');
const rsyncArgs = buildRsyncArgs(config);
const { stdout } = await spawnAsync('rsync', rsyncArgs);
const stats = parseRsyncStats(stdout);
const backedUpFiles = files.map(file => file.relativePath);
@@ -503,8 +537,7 @@ async function performRsyncBackup(config, files) {
backedUpCount: typeof stats.filesTransferred === 'number' ? stats.filesTransferred : backedUpFiles.length,
backedUpSize: totalSize,
backedUpFiles,
backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`,
rsyncCommand: command
backupPath: `${config.backup_rsync_host}:${config.backup_rsync_path}`
};
}
+15 -22
View File
@@ -1,9 +1,7 @@
const fs = require('fs').promises;
const path = require('path');
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const crypto = require('crypto');
const { spawnAsync, spawnToFile } = require('../utils/safeExec');
const zlib = require('zlib');
const { pipeline } = require('stream/promises');
const { createReadStream, createWriteStream } = require('fs');
@@ -163,10 +161,10 @@ class DatabaseBackupService {
try {
// Use SQLite's backup API for consistency
await execAsync(`sqlite3 "${dbPath}" ".backup '${tempPath}'"`);
await spawnAsync('sqlite3', [dbPath, `.backup '${tempPath}'`]);
// Verify the backup
const verifyResult = await execAsync(`sqlite3 "${tempPath}" "PRAGMA integrity_check"`);
const verifyResult = await spawnAsync('sqlite3', [tempPath, 'PRAGMA integrity_check']);
if (!verifyResult.stdout.includes('ok')) {
throw new Error('Backup integrity check failed');
}
@@ -192,14 +190,6 @@ class DatabaseBackupService {
async createPostgreSQLBackup(outputPath, options = {}) {
const { host, port, user, password, database } = knexConfig.connection;
// Build connection string with proper escaping
const connectionParts = [
`host=${host}`,
`port=${port}`,
`dbname=${database}`,
`user=${user}`
];
// Set PGPASSWORD environment variable for security
const env = { ...process.env };
if (password) {
@@ -227,14 +217,17 @@ class DatabaseBackupService {
pgDumpOptions.push('--compress=6');
}
const command = `pg_dump "${connectionParts.join(' ')}" ${pgDumpOptions.join(' ')} > "${outputPath}"`;
const pgDumpArgs = [
...pgDumpOptions,
'-h', host,
'-p', String(port),
'-U', user,
'-d', database
];
try {
const { stderr } = await execAsync(command, {
env,
maxBuffer: 1024 * 1024 * 100 // 100MB buffer
});
const { stderr } = await spawnToFile('pg_dump', pgDumpArgs, outputPath, { env });
// pg_dump writes progress to stderr, not an error
if (stderr && !stderr.includes('dump complete')) {
logger.warn('pg_dump warnings:', stderr);
@@ -261,7 +254,7 @@ class DatabaseBackupService {
try {
if (this.dbType === 'sqlite') {
// For SQLite, we can directly check integrity
const result = await execAsync(`sqlite3 "${backupPath}" "PRAGMA integrity_check"`);
const result = await spawnAsync('sqlite3', [backupPath, 'PRAGMA integrity_check']);
if (!result.stdout.includes('ok')) {
throw new Error('Backup integrity check failed');
}
+217 -93
View File
@@ -99,113 +99,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 = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/+$/, '');
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 +194,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 +207,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 +228,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 +236,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 +256,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 +299,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 +681,6 @@ module.exports = {
processEmailQueue,
queueEmail,
stopEmailQueueProcessor,
testEmailConnection
testEmailConnection,
wrapEmailHtml
};
+195
View File
@@ -0,0 +1,195 @@
/**
* Environment Detection Service
* Detects the deployment environment and generates update instructions accordingly.
*/
const fs = require('fs');
const path = require('path');
const logger = require('../utils/logger');
/**
* Detect the current deployment environment
* @returns {Object} Environment information
*/
async function detectEnvironment() {
// Check for Docker environment
const isDocker = fs.existsSync('/.dockerenv') ||
process.env.DOCKER_CONTAINER === 'true';
// Determine project root (services -> src -> backend)
const projectRoot = path.join(__dirname, '../../..');
// Check for git repository
const isGit = fs.existsSync(path.join(projectRoot, '.git'));
// Check for docker-compose files
const hasDockerCompose = fs.existsSync(path.join(projectRoot, 'docker-compose.yml')) ||
fs.existsSync(path.join(projectRoot, 'docker-compose.yaml'));
// Get app version
let appVersion = '0.0.0';
try {
const packagePath = path.join(__dirname, '../../package.json');
const packageContent = fs.readFileSync(packagePath, 'utf8');
const packageJson = JSON.parse(packageContent);
appVersion = packageJson.version || '0.0.0';
} catch (err) {
logger.warn('Could not read package.json for version:', err.message);
}
// Determine environment type
let type;
if (isDocker) {
type = 'docker';
} else if (isGit) {
type = 'git';
} else {
type = 'standalone';
}
return {
type,
isDocker,
isGit,
hasDockerCompose,
platform: process.platform,
nodeVersion: process.version,
appVersion
};
}
/**
* Generate environment-specific update instructions
* @param {Object} env - Environment info from detectEnvironment()
* @param {string} targetVersion - Target version to update to
* @returns {Object} Update instructions with pre-checks, steps, and post-checks
*/
function generateUpdateInstructions(env, targetVersion) {
const instructions = {
preChecks: [
{
id: 'backup',
text: 'I have backed up my database',
required: true
},
{
id: 'no-uploads',
text: 'No uploads are currently in progress',
required: true
},
{
id: 'downtime-aware',
text: 'I understand the application will restart during update',
required: false
}
],
steps: [],
postChecks: [
'Verify the application starts correctly',
'Check the version in Admin -> System',
'Review release notes for any breaking changes or required actions'
],
warnings: []
};
if (env.isDocker) {
instructions.environmentName = 'Docker';
instructions.steps = [
{
description: 'Pull latest images',
command: 'docker compose pull',
note: 'Downloads the new version images'
},
{
description: 'Recreate containers with new images',
command: 'docker compose up -d',
note: 'Restarts containers with new version'
},
{
description: 'Watch logs for startup (optional)',
command: 'docker compose logs -f backend',
note: 'Press Ctrl+C to exit logs',
optional: true
}
];
instructions.warnings.push('Make sure you are in the directory containing your docker-compose.yml file');
} else if (env.isGit) {
instructions.environmentName = 'Git (Development)';
instructions.steps = [
{
description: 'Fetch latest changes',
command: 'git fetch origin',
note: 'Downloads references from remote'
},
{
description: 'Switch to new version tag',
command: `git checkout v${targetVersion}`,
note: 'Switches to the release version'
},
{
description: 'Install backend dependencies',
command: 'cd backend && npm install',
note: 'Updates npm packages'
},
{
description: 'Build frontend',
command: 'cd frontend && npm install && npm run build',
note: 'Compiles the frontend application'
},
{
description: 'Run database migrations',
command: 'cd backend && npm run migrate',
note: 'Updates database schema'
},
{
description: 'Restart application',
command: '# Restart your application (pm2, systemd, etc.)',
note: 'Method depends on your setup - e.g., pm2 restart picpeak'
}
];
instructions.warnings.push('Adjust the restart command based on your process manager (pm2, systemd, etc.)');
} else {
instructions.environmentName = 'Standalone';
instructions.steps = [
{
description: 'Download release archive',
command: `# Download v${targetVersion} from GitHub Releases`,
note: `https://github.com/the-luap/picpeak/releases/tag/v${targetVersion}`
},
{
description: 'Backup current installation',
command: '# Create backup of current files',
note: 'Keep a copy of your current installation'
},
{
description: 'Extract and replace application files',
command: '# Extract release archive to installation directory',
note: 'Preserve your .env file and storage directory'
},
{
description: 'Install dependencies',
command: 'cd backend && npm install --production',
note: 'Updates npm packages'
},
{
description: 'Run database migrations',
command: 'cd backend && npm run migrate',
note: 'Updates database schema'
},
{
description: 'Restart application',
command: '# Restart your application service',
note: 'Method depends on your setup'
}
];
instructions.warnings.push('Make sure to preserve your .env file and storage directory when updating');
instructions.warnings.push('Consider creating a full backup before updating');
}
return instructions;
}
module.exports = {
detectEnvironment,
generateUpdateInstructions
};
+20 -5
View File
@@ -14,8 +14,9 @@ const { formatBoolean } = require('../utils/dbCompat');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('./shareLinkService');
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
const eventTypeService = require('./eventTypeService');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Cache for schema detection
let customerColumnCache = null;
@@ -67,13 +68,23 @@ const mapEventForApi = (event) => {
/**
* Generate a unique slug for an event
* @param {string} eventType
* @param {string} eventType - Event type identifier (slug_prefix or legacy type)
* @param {string} eventName
* @param {string} eventDate
* @returns {Promise<string>}
*/
const generateUniqueSlug = async (eventType, eventName, eventDate) => {
const baseSlug = `${eventType}-${eventName.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${eventDate}`;
// Get the slug_prefix from event type (supports both new dynamic types and legacy)
const eventTypeInfo = await eventTypeService.getEventTypeForSlug(eventType);
const slugPrefix = eventTypeInfo.slug_prefix || eventType;
const processedName = eventName
.toLowerCase()
.replace(/[^a-z0-9]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
const baseSlug = `${slugPrefix}-${processedName}-${eventDate}`;
let slug = baseSlug;
let counter = 1;
@@ -127,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);
@@ -196,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
+314
View File
@@ -0,0 +1,314 @@
/**
* Event Type Service Layer
* Handles all event type-related business logic
*
* @module services/eventTypeService
*/
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
/**
* Get all event types
* @param {Object} options - Filter options
* @param {boolean} options.activeOnly - Only return active types
* @returns {Promise<Array>} - Array of event types
*/
const getAllEventTypes = async (options = {}) => {
const { activeOnly = false } = options;
let query = db('event_types').select('*');
if (activeOnly) {
query = query.where('is_active', formatBoolean(true));
}
const types = await query.orderBy('display_order', 'asc');
return types;
};
/**
* Get active event types for dropdown/selection
* @returns {Promise<Array>} - Array of active event types
*/
const getActiveEventTypes = async () => {
return getAllEventTypes({ activeOnly: true });
};
/**
* Get an event type by ID
* @param {number} id - Event type ID
* @returns {Promise<Object|null>}
*/
const getEventTypeById = async (id) => {
const eventType = await db('event_types').where('id', id).first();
return eventType || null;
};
/**
* Get an event type by slug prefix
* @param {string} slugPrefix - The slug prefix
* @returns {Promise<Object|null>}
*/
const getEventTypeBySlugPrefix = async (slugPrefix) => {
const eventType = await db('event_types')
.where('slug_prefix', slugPrefix.toLowerCase())
.first();
return eventType || null;
};
/**
* Check if a slug prefix is valid (exists in event_types or is a legacy type)
* @param {string} slugPrefix - The slug prefix to validate
* @returns {Promise<boolean>}
*/
const isValidEventType = async (slugPrefix) => {
const normalized = slugPrefix.toLowerCase();
// Check in database
const eventType = await getEventTypeBySlugPrefix(normalized);
if (eventType && eventType.is_active) {
return true;
}
// Legacy fallback: Accept old hardcoded values for backward compatibility
const legacyTypes = ['wedding', 'birthday', 'corporate', 'other'];
return legacyTypes.includes(normalized);
};
/**
* Get all valid slug prefixes (for validation)
* @returns {Promise<string[]>}
*/
const getValidSlugPrefixes = async () => {
const types = await db('event_types')
.where('is_active', formatBoolean(true))
.select('slug_prefix');
return types.map(t => t.slug_prefix);
};
/**
* Create a new event type
* @param {Object} eventTypeData - Event type data
* @returns {Promise<Object>} - Created event type
*/
const createEventType = async (eventTypeData) => {
const {
name,
slug_prefix,
emoji,
theme_preset,
theme_config,
display_order
} = eventTypeData;
// Normalize slug_prefix
const normalizedSlugPrefix = slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// Check for duplicate slug_prefix
const existing = await getEventTypeBySlugPrefix(normalizedSlugPrefix);
if (existing) {
const error = new Error('An event type with this slug prefix already exists');
error.code = 'DUPLICATE_SLUG_PREFIX';
throw error;
}
// Get max display order if not provided
let finalDisplayOrder = display_order;
if (finalDisplayOrder === undefined || finalDisplayOrder === null) {
const maxOrder = await db('event_types').max('display_order as max').first();
finalDisplayOrder = (maxOrder?.max || 0) + 1;
}
const insertData = {
name,
slug_prefix: normalizedSlugPrefix,
emoji: emoji || '📷',
theme_preset: theme_preset || 'default',
theme_config: theme_config ? JSON.stringify(theme_config) : null,
display_order: finalDisplayOrder,
is_system: false,
is_active: true,
created_at: new Date(),
updated_at: new Date()
};
const insertResult = await db('event_types').insert(insertData).returning('id');
const eventTypeId = insertResult[0]?.id || insertResult[0];
return getEventTypeById(eventTypeId);
};
/**
* Update an event type
* @param {number} id - Event type ID
* @param {Object} updates - Fields to update
* @returns {Promise<Object>} - Updated event type
*/
const updateEventType = async (id, updates) => {
const eventType = await getEventTypeById(id);
if (!eventType) {
const error = new Error('Event type not found');
error.code = 'NOT_FOUND';
throw error;
}
// Build update object
const updateData = {};
if (updates.name !== undefined) {
updateData.name = updates.name;
}
if (updates.slug_prefix !== undefined) {
const normalizedSlugPrefix = updates.slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// Check for duplicate (excluding current)
const existing = await db('event_types')
.where('slug_prefix', normalizedSlugPrefix)
.whereNot('id', id)
.first();
if (existing) {
const error = new Error('An event type with this slug prefix already exists');
error.code = 'DUPLICATE_SLUG_PREFIX';
throw error;
}
updateData.slug_prefix = normalizedSlugPrefix;
}
if (updates.emoji !== undefined) {
updateData.emoji = updates.emoji;
}
if (updates.theme_preset !== undefined) {
updateData.theme_preset = updates.theme_preset;
}
if (updates.theme_config !== undefined) {
updateData.theme_config = updates.theme_config ? JSON.stringify(updates.theme_config) : null;
}
if (updates.display_order !== undefined) {
updateData.display_order = updates.display_order;
}
if (updates.is_active !== undefined) {
updateData.is_active = formatBoolean(updates.is_active);
}
updateData.updated_at = new Date();
await db('event_types').where('id', id).update(updateData);
return getEventTypeById(id);
};
/**
* Delete an event type
* @param {number} id - Event type ID
* @returns {Promise<Object>}
*/
const deleteEventType = async (id) => {
const eventType = await getEventTypeById(id);
if (!eventType) {
const error = new Error('Event type not found');
error.code = 'NOT_FOUND';
throw error;
}
// Prevent deletion of system types
if (eventType.is_system) {
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
error.code = 'SYSTEM_TYPE';
throw error;
}
// Check if any events use this type
const eventsUsingType = await db('events')
.where('event_type', eventType.slug_prefix)
.count('id as count')
.first();
if (eventsUsingType && parseInt(eventsUsingType.count) > 0) {
const error = new Error(`Cannot delete: ${eventsUsingType.count} events are using this type. Deactivate it instead or reassign those events.`);
error.code = 'IN_USE';
throw error;
}
await db('event_types').where('id', id).del();
return { success: true, deleted: eventType };
};
/**
* Reorder event types
* @param {Array} orderedIds - Array of IDs in new order
* @returns {Promise<Array>}
*/
const reorderEventTypes = async (orderedIds) => {
await db.transaction(async (trx) => {
for (let i = 0; i < orderedIds.length; i++) {
await trx('event_types')
.where('id', orderedIds[i])
.update({ display_order: i + 1, updated_at: new Date() });
}
});
return getAllEventTypes();
};
/**
* Get event type info for slug generation
* Returns the slug_prefix to use for a given event type identifier
* @param {string} eventTypeIdentifier - Either an ID or slug_prefix
* @returns {Promise<Object>} - Event type with slug_prefix and theme_preset
*/
const getEventTypeForSlug = async (eventTypeIdentifier) => {
// Try to find by slug_prefix first
let eventType = await getEventTypeBySlugPrefix(eventTypeIdentifier);
if (eventType) {
return eventType;
}
// Try by ID if numeric
if (!isNaN(eventTypeIdentifier)) {
eventType = await getEventTypeById(parseInt(eventTypeIdentifier));
if (eventType) {
return eventType;
}
}
// Fallback for legacy types - return a compatible object
const legacyDefaults = {
wedding: { slug_prefix: 'wedding', theme_preset: 'elegantWedding', emoji: '💒' },
birthday: { slug_prefix: 'birthday', theme_preset: 'birthdayFun', emoji: '🎂' },
corporate: { slug_prefix: 'corporate', theme_preset: 'corporateTimeline', emoji: '🏢' },
other: { slug_prefix: 'other', theme_preset: 'default', emoji: '📸' }
};
const normalized = eventTypeIdentifier.toLowerCase();
if (legacyDefaults[normalized]) {
return legacyDefaults[normalized];
}
// Default fallback
return { slug_prefix: 'event', theme_preset: 'default', emoji: '📷' };
};
module.exports = {
getAllEventTypes,
getActiveEventTypes,
getEventTypeById,
getEventTypeBySlugPrefix,
isValidEventType,
getValidSlugPrefixes,
createEventType,
updateEventType,
deleteEventType,
reorderEventTypes,
getEventTypeForSlug
};
@@ -21,9 +21,11 @@ async function checkExpirations() {
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now
// Check for events needing warning emails
// Skip events with null expires_at (they never expire)
const eventsNeedingWarning = await db('events')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.whereNotNull('expires_at')
.where('expires_at', '<=', warningDate)
.where('expires_at', '>', now);
@@ -40,9 +42,11 @@ async function checkExpirations() {
}
// Check for expired events
// Skip events with null expires_at (they never expire)
const expiredEvents = await db('events')
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.whereNotNull('expires_at')
.where('expires_at', '<=', now);
for (const event of expiredEvents) {
+221 -1
View File
@@ -1,4 +1,5 @@
const sharp = require('sharp');
const exifr = require('exifr');
const path = require('path');
const fs = require('fs').promises;
const logger = require('../utils/logger');
@@ -119,6 +120,9 @@ async function generateThumbnail(imagePath, options = {}) {
failOnError: false // Don't fail on minor issues
});
// Strip EXIF/metadata from thumbnails (privacy: prevent GPS leak etc.)
sharpInstance = sharpInstance.withMetadata(false);
// Apply resize with configured settings
// For square thumbnails with 'cover' fit, we crop to center
sharpInstance = sharpInstance.resize(settings.width, settings.height, {
@@ -287,4 +291,220 @@ async function generateVideoPlaceholder(originalFilename, options = {}) {
}
}
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail, generateVideoPlaceholder };
// Hero image settings - optimized for large displays
const DEFAULT_HERO_WIDTH = 1920;
const DEFAULT_HERO_HEIGHT = 1080;
const DEFAULT_HERO_QUALITY = 85;
const DEFAULT_HERO_FORMAT = 'jpeg';
const getHeroPath = () => path.join(getStoragePath(), 'heroes');
/**
* Generate a hero-optimized image for gallery headers
* Outputs a 1920x1080 image suitable for full-width hero sections
*/
async function generateHeroImage(imagePath, options = {}) {
const filename = path.basename(imagePath);
const heroFilename = `hero_${filename}`;
const heroDir = getHeroPath();
const heroPath = path.join(heroDir, heroFilename);
// Ensure hero directory exists
await fs.mkdir(heroDir, { recursive: true });
// Check if we need to regenerate
if (options.regenerate) {
try {
await fs.unlink(heroPath);
logger.info(`Deleted existing hero image: ${heroPath}`);
} catch (err) {
// File might not exist, that's okay
}
}
try {
// First, verify the source image is complete and valid
const metadata = await sharp(imagePath).metadata();
if (!metadata.width || !metadata.height) {
throw new Error('Invalid image metadata - file may be incomplete');
}
// Calculate dimensions to maintain aspect ratio while fitting within hero bounds
const heroWidth = options.width || DEFAULT_HERO_WIDTH;
const heroHeight = options.height || DEFAULT_HERO_HEIGHT;
const quality = options.quality || DEFAULT_HERO_QUALITY;
// Create sharp instance with memory-efficient settings
let sharpInstance = sharp(imagePath, {
limitInputPixels: 268402689,
sequentialRead: true,
failOnError: false
});
// Strip EXIF/metadata from hero images (privacy: prevent GPS leak etc.)
sharpInstance = sharpInstance.withMetadata(false);
// Resize to fit hero dimensions while maintaining aspect ratio
// Use 'cover' to fill the hero area (crops if needed)
sharpInstance = sharpInstance.resize(heroWidth, heroHeight, {
withoutEnlargement: false, // Allow upscaling for small images
fit: 'cover',
position: 'center'
});
// Apply JPEG format with high quality
sharpInstance = sharpInstance.jpeg({
quality: quality,
progressive: true,
mozjpeg: true
});
// Save the hero image
await sharpInstance.toFile(heroPath);
// Verify the hero image was created successfully
const stats = await fs.stat(heroPath);
if (stats.size === 0) {
throw new Error('Generated hero image is empty');
}
logger.info(`Generated hero image for ${filename}: ${heroPath}`);
return path.relative(getStoragePath(), heroPath);
} catch (error) {
const msg = (error && error.message) ? error.message : String(error);
logger.error(`Failed to generate hero image for ${filename}: ${msg}`);
// Clean up any partially created file
try {
await fs.unlink(heroPath);
} catch (unlinkErr) {
// Ignore unlink errors
}
return null;
}
}
/**
* Check if a hero image exists and is valid
*/
async function isHeroValid(heroPath) {
try {
const fullPath = path.join(getStoragePath(), heroPath);
const stats = await fs.stat(fullPath);
if (stats.size === 0) {
return false;
}
// Try to read metadata to ensure it's a valid image
await sharp(fullPath).metadata();
return true;
} catch (error) {
return false;
}
}
/**
* Ensure a hero image exists for a photo, regenerate if needed
*/
async function ensureHeroImage(photo) {
const { db } = require('../database/db');
const { resolvePhotoFilePath } = require('./photoResolver');
let originalPath;
try {
const event = await db('events').where('id', photo.event_id).first();
originalPath = resolvePhotoFilePath(event, photo);
logger.info(`Ensuring hero image for photo ${photo.id} from source: ${originalPath}`);
} catch (e) {
const msg = (e && e.message) ? e.message : String(e);
logger.error(`Failed to resolve original path for hero image (photo ${photo.id}): ${msg}`);
return null;
}
// Check if hero image exists and is valid
if (photo.hero_path) {
const isValid = await isHeroValid(photo.hero_path);
if (isValid) {
return photo.hero_path;
}
logger.warn(`Invalid hero image detected for photo ${photo.id}, regenerating...`);
}
// Generate new hero image
const newHeroPath = await generateHeroImage(originalPath, { regenerate: true });
if (newHeroPath) {
// Update database with new hero path
await db('photos')
.where({ id: photo.id })
.update({ hero_path: newHeroPath });
logger.info(`Regenerated hero image for photo ${photo.id}`);
return newHeroPath;
}
return null;
}
/**
* Extract capture date from EXIF metadata
* @param {string} imagePath - Path to the image file
* @returns {Date|null} - The capture date or null if not available
*/
async function extractCaptureDate(imagePath) {
try {
// Parse EXIF data, looking for common date fields
const exif = await exifr.parse(imagePath, {
pick: ['DateTimeOriginal', 'CreateDate', 'DateTimeDigitized', 'ModifyDate']
});
if (!exif) {
return null;
}
// Priority order: DateTimeOriginal > CreateDate > DateTimeDigitized > ModifyDate
const captureDate = exif.DateTimeOriginal ||
exif.CreateDate ||
exif.DateTimeDigitized ||
exif.ModifyDate;
if (captureDate) {
// exifr returns Date objects directly when parsing dates
if (captureDate instanceof Date) {
// Validate the date is reasonable (not in the future, not before 1990)
const now = new Date();
const minDate = new Date('1990-01-01');
if (captureDate > minDate && captureDate <= now) {
return captureDate;
}
}
// Handle string dates if necessary
if (typeof captureDate === 'string') {
const parsed = new Date(captureDate);
if (!isNaN(parsed.getTime())) {
return parsed;
}
}
}
return null;
} catch (error) {
// Log only as debug - many images don't have EXIF data
logger.debug(`Could not extract EXIF date from ${path.basename(imagePath)}:`, error.message);
return null;
}
}
module.exports = {
generateThumbnail,
isThumbnailValid,
ensureThumbnail,
generateVideoPlaceholder,
generateHeroImage,
isHeroValid,
ensureHeroImage,
extractCaptureDate
};
+19 -19
View File
@@ -23,13 +23,13 @@ class PhotoExportService {
*/
async getPhotosWithFeedback(eventId, photoIds = null) {
let query = db('photos')
.leftJoin('categories', 'photos.category_id', 'categories.id')
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
.where('photos.event_id', eventId)
.select(
'photos.id',
'photos.filename',
'photos.original_filename',
'photos.file_path',
'photos.path',
'photos.average_rating',
'photos.feedback_count',
'photos.like_count',
@@ -37,9 +37,9 @@ class PhotoExportService {
'photos.comment_count',
'photos.width',
'photos.height',
'photos.file_size',
'photos.created_at',
'categories.name as category_name'
'photos.size_bytes',
'photos.uploaded_at',
'photo_categories.name as category_name'
)
.orderBy('photos.filename', 'asc');
@@ -86,7 +86,7 @@ class PhotoExportService {
const { filename_format = 'original', separator = 'newline' } = options;
const filenames = photos.map(photo =>
filename_format === 'original' ? photo.original_filename : photo.filename
filename_format === 'original' ? (photo.original_filename || photo.filename) : photo.filename
);
let content;
@@ -126,14 +126,14 @@ class PhotoExportService {
'category',
'width',
'height',
'file_size',
'created_at'
'size_bytes',
'uploaded_at'
];
const rows = photos.map(photo => [
filename_format === 'original' ? photo.original_filename : photo.filename,
filename_format === 'original' ? (photo.original_filename || photo.filename) : photo.filename,
photo.original_filename || '',
photo.average_rating ? photo.average_rating.toFixed(2) : '0.00',
photo.average_rating ? parseFloat(photo.average_rating).toFixed(2) : '0.00',
photo.feedback_count || 0,
photo.like_count || 0,
photo.favorite_count || 0,
@@ -141,8 +141,8 @@ class PhotoExportService {
photo.category_name || '',
photo.width || '',
photo.height || '',
photo.file_size || '',
photo.created_at ? new Date(photo.created_at).toISOString() : ''
photo.size_bytes || '',
photo.uploaded_at ? new Date(photo.uploaded_at).toISOString() : ''
]);
const csvContent = [
@@ -170,7 +170,7 @@ class PhotoExportService {
for (const photo of photos) {
const baseFilename = filename_format === 'original'
? photo.original_filename
? (photo.original_filename || photo.filename)
: photo.filename;
const xmpFilename = this.xmpGenerator.getXmpFilename(baseFilename);
const xmpContent = this.xmpGenerator.generateXmp(photo, options);
@@ -209,21 +209,21 @@ class PhotoExportService {
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
original_filename: photo.original_filename,
original_filename: photo.original_filename || null,
category: photo.category_name || null,
rating: {
average: photo.average_rating ? parseFloat(photo.average_rating.toFixed(2)) : 0,
average: photo.average_rating ? parseFloat(parseFloat(photo.average_rating).toFixed(2)) : 0,
count: photo.feedback_count || 0
},
likes: photo.like_count || 0,
favorites: photo.favorite_count || 0,
comments: photo.comment_count || 0,
dimensions: {
width: photo.width,
height: photo.height
width: photo.width || null,
height: photo.height || null
},
file_size: photo.file_size,
created_at: photo.created_at
size_bytes: photo.size_bytes || null,
uploaded_at: photo.uploaded_at || null
}))
};
+23 -1
View File
@@ -158,6 +158,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
// Generate thumbnail and extract metadata
let thumbnailPath;
let videoMetadata = null;
let imageMetadata = null;
if (isVideo) {
// Process video: extract metadata and generate thumbnail
@@ -169,8 +170,22 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
videoMetadata = result.metadata;
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
} else {
// Process image: generate thumbnail
// Process image: generate thumbnail and extract dimensions
thumbnailPath = await generateThumbnail(newPath);
// Extract image dimensions using sharp
try {
const sharp = require('sharp');
const metadata = await sharp(newPath).metadata();
if (metadata.width && metadata.height) {
imageMetadata = {
width: metadata.width,
height: metadata.height
};
}
} catch (metadataError) {
console.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message);
}
}
// Calculate relative paths
@@ -186,6 +201,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
const photoData = {
event_id: eventId,
filename: newFilename,
original_filename: file.originalname,
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
@@ -205,6 +221,12 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
photoData.height = videoMetadata.height;
}
// Add image dimensions if available
if (!isVideo && imageMetadata) {
photoData.width = imageMetadata.width;
photoData.height = imageMetadata.height;
}
if (supportsReturning) {
insertResult = await trx('photos')
.insert(photoData)
+1 -1
View File
@@ -10,7 +10,7 @@ const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/**
* Get photos for an event with optional filtering
+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 {
+22 -37
View File
@@ -4,9 +4,7 @@ const crypto = require('crypto');
const zlib = require('zlib');
const { pipeline } = require('stream/promises');
const { createReadStream, createWriteStream } = require('fs');
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const { spawnAsync, spawnToFile, spawnFromFile } = require('../utils/safeExec');
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const logger = require('../utils/logger');
@@ -418,12 +416,14 @@ class RestoreService {
let availableBytes = 0;
let diskCheckSucceeded = false;
try {
const { exec } = require('child_process');
const execAsync = promisify(exec);
// Use root path as fallback if storage path doesn't exist yet
const checkPath = await fs.access(storagePath).then(() => storagePath).catch(() => '/');
const { stdout } = await execAsync(`df -k "${checkPath}" | tail -1 | awk '{print $4}'`);
const parsed = parseInt(stdout.trim());
const { stdout } = await spawnAsync('df', ['-k', checkPath]);
// Parse df output: last line, 4th column is available KB
const lines = stdout.trim().split('\n');
const lastLine = lines[lines.length - 1];
const columns = lastLine.trim().split(/\s+/);
const parsed = parseInt(columns[3]);
if (!isNaN(parsed) && parsed > 0) {
availableBytes = parsed * 1024; // Convert from KB to bytes
diskCheckSucceeded = true;
@@ -492,15 +492,12 @@ class RestoreService {
if (this.dbType === 'sqlite') {
const dbPath = knexConfig.connection.filename;
await execAsync(`sqlite3 "${dbPath}" ".backup '${dbBackupPath}'"`);
await spawnAsync('sqlite3', [dbPath, `.backup '${dbBackupPath}'`]);
} else {
// PostgreSQL backup
const { host, port, user, password, database } = knexConfig.connection;
const env = { ...process.env, PGPASSWORD: password };
await execAsync(
`pg_dump -h ${host} -p ${port} -U ${user} -d ${database} > "${dbBackupPath}"`,
{ env }
);
await spawnToFile('pg_dump', ['-h', host, '-p', String(port), '-U', user, '-d', database], dbBackupPath, { env });
}
// Compress database backup
@@ -513,8 +510,8 @@ class RestoreService {
this.log('info', 'Backing up current files...');
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const filesBackupPath = path.join(backupPath, 'files.tar.gz');
await execAsync(`tar -czf "${filesBackupPath}" -C "${path.dirname(storagePath)}" "${path.basename(storagePath)}"`);
await spawnAsync('tar', ['-czf', filesBackupPath, '-C', path.dirname(storagePath), path.basename(storagePath)]);
}
// Create backup manifest
@@ -696,10 +693,10 @@ class RestoreService {
try {
// Restore from backup
await execAsync(`sqlite3 "${dbPath}" ".restore '${restoreFile}'"`);
await spawnAsync('sqlite3', [dbPath, `.restore '${restoreFile}'`]);
// Verify integrity
const integrityCheck = await execAsync(`sqlite3 "${dbPath}" "PRAGMA integrity_check"`);
const integrityCheck = await spawnAsync('sqlite3', [dbPath, 'PRAGMA integrity_check']);
if (!integrityCheck.stdout.includes('ok')) {
throw new Error('Database integrity check failed after restore');
}
@@ -722,21 +719,12 @@ class RestoreService {
// Drop and recreate database (extremely dangerous!)
this.log('warn', 'Dropping and recreating PostgreSQL database...');
await execAsync(
`psql -h ${host} -p ${port} -U ${user} -c "DROP DATABASE IF EXISTS ${database}"`,
{ env }
);
await execAsync(
`psql -h ${host} -p ${port} -U ${user} -c "CREATE DATABASE ${database}"`,
{ env }
);
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-c', `DROP DATABASE IF EXISTS ${database}`], { env });
await spawnAsync('psql', ['-h', host, '-p', String(port), '-U', user, '-c', `CREATE DATABASE ${database}`], { env });
// Restore from backup
await execAsync(
`psql -h ${host} -p ${port} -U ${user} -d ${database} < "${restoreFile}"`,
{ env, maxBuffer: 1024 * 1024 * 100 } // 100MB buffer
);
await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], restoreFile, { env });
}
// Re-initialize database connection
@@ -987,14 +975,11 @@ class RestoreService {
if (this.dbType === 'sqlite') {
const dbPath = knexConfig.connection.filename;
await execAsync(`sqlite3 "${dbPath}" ".restore '${decompressedPath}'"`);
await spawnAsync('sqlite3', [dbPath, `.restore '${decompressedPath}'`]);
} else {
const { host, port, user, password, database } = knexConfig.connection;
const env = { ...process.env, PGPASSWORD: password };
await execAsync(
`psql -h ${host} -p ${port} -U ${user} -d ${database} < "${decompressedPath}"`,
{ env }
);
await spawnFromFile('psql', ['-h', host, '-p', String(port), '-U', user, '-d', database], decompressedPath, { env });
}
await fs.unlink(decompressedPath);
@@ -1004,7 +989,7 @@ class RestoreService {
const filesBackupPath = path.join(preRestoreBackupPath, 'files.tar.gz');
if (await fs.access(filesBackupPath).then(() => true).catch(() => false)) {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
await execAsync(`tar -xzf "${filesBackupPath}" -C "${path.dirname(storagePath)}"`);
await spawnAsync('tar', ['-xzf', filesBackupPath, '-C', path.dirname(storagePath)]);
}
this.log('info', 'Rollback completed successfully');
+128
View File
@@ -0,0 +1,128 @@
const { db } = require('../database/db');
let cachedRobotsTxt = null;
let cacheTimestamp = 0;
const CACHE_TTL_MS = 60 * 1000; // 60 seconds
function parseSetting(raw) {
if (raw === null || raw === undefined) return null;
if (typeof raw !== 'string') return raw;
try {
return JSON.parse(raw);
} catch {
return raw;
}
}
async function getSeoSettings() {
const rows = await db('app_settings')
.where('setting_type', 'seo')
.select('setting_key', 'setting_value');
const settings = {};
for (const row of rows) {
settings[row.setting_key] = parseSetting(row.setting_value);
}
return settings;
}
const SOCIAL_BOTS = [
'Twitterbot',
'facebookexternalhit',
'LinkedInBot',
'Slackbot',
'WhatsApp',
'TelegramBot',
'Discordbot'
];
async function generateRobotsTxt() {
const now = Date.now();
if (cachedRobotsTxt && (now - cacheTimestamp) < CACHE_TTL_MS) {
return cachedRobotsTxt;
}
const settings = await getSeoSettings();
const allowIndexing = settings.seo_allow_indexing === true;
const blockAiCrawlers = settings.seo_block_ai_crawlers !== false;
const blockSocialBots = settings.seo_block_social_bots === true;
const aiAgents = Array.isArray(settings.seo_blocked_ai_agents)
? settings.seo_blocked_ai_agents
: [];
const customRules = Array.isArray(settings.seo_custom_rules)
? settings.seo_custom_rules
: [];
const sitemapUrl = settings.seo_sitemap_url || '';
const lines = [];
// Always block admin and API paths for all agents
lines.push('# Protected paths');
lines.push('User-agent: *');
lines.push('Disallow: /admin');
lines.push('Disallow: /api');
lines.push('');
if (!allowIndexing) {
// Block everything for all agents
lines.push('# Indexing disabled - block all crawlers');
lines.push('User-agent: *');
lines.push('Disallow: /');
lines.push('');
}
// Block AI crawlers if enabled
if (blockAiCrawlers && aiAgents.length > 0) {
lines.push('# AI/LLM crawler blocking');
for (const agent of aiAgents) {
lines.push(`User-agent: ${agent}`);
lines.push('Disallow: /');
lines.push('');
}
}
// Block social bots if enabled
if (blockSocialBots) {
lines.push('# Social media bot blocking');
for (const bot of SOCIAL_BOTS) {
lines.push(`User-agent: ${bot}`);
lines.push('Disallow: /');
lines.push('');
}
}
// Custom rules
if (customRules.length > 0) {
lines.push('# Custom rules');
for (const rule of customRules) {
if (rule.userAgent && Array.isArray(rule.disallow)) {
lines.push(`User-agent: ${rule.userAgent}`);
for (const path of rule.disallow) {
lines.push(`Disallow: ${path}`);
}
lines.push('');
}
}
}
// Sitemap
if (sitemapUrl) {
lines.push(`Sitemap: ${sitemapUrl}`);
lines.push('');
}
const result = lines.join('\n');
cachedRobotsTxt = result;
cacheTimestamp = now;
return result;
}
function clearRobotsTxtCache() {
cachedRobotsTxt = null;
cacheTimestamp = 0;
}
module.exports = {
generateRobotsTxt,
clearRobotsTxtCache
};
+64 -7
View File
@@ -157,6 +157,8 @@ class SecureImageService {
/**
* Process image with protection measures
* For basic/standard protection without fingerprinting, returns original file
* For enhanced/maximum protection, applies quality reduction and fingerprinting
*/
async processProtectedImage(imagePath, options = {}) {
const {
@@ -169,11 +171,58 @@ class SecureImageService {
} = options;
try {
// For basic protection level, always return original file without processing
if (protectionLevel === 'basic') {
return await fs.readFile(imagePath);
}
// For standard protection without fingerprinting, return original file
// This avoids unnecessary recompression when no protection features are needed
if (protectionLevel === 'standard' && !addFingerprint && !fragmentImage) {
return await fs.readFile(imagePath);
}
// Get metadata to check if processing is actually needed
const metadata = await sharp(imagePath).metadata();
// For standard protection with fingerprint only (no resize needed, no quality change),
// we can add fingerprint without full recompression by preserving format
const needsResize = metadata.width > maxWidth || metadata.height > maxHeight;
const needsQualityReduction = protectionLevel === 'enhanced' || protectionLevel === 'maximum';
// If standard protection and only fingerprinting is needed, and image doesn't need resize,
// just add metadata without recompressing
if (protectionLevel === 'standard' && addFingerprint && !needsResize) {
let image = sharp(imagePath);
// Add fingerprint to metadata without changing image quality
const fingerprint = crypto.randomBytes(16).toString('hex');
// Preserve original format with high quality
const format = metadata.format || 'jpeg';
if (format === 'png') {
image = image.png({ compressionLevel: 6 });
} else if (format === 'webp') {
image = image.webp({ quality: 95 });
} else {
image = image.jpeg({ quality: 100, mozjpeg: true });
}
image = image.withMetadata({
exif: {
[sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}`
}
});
return await image.toBuffer();
}
// For enhanced/maximum protection or when resize is needed, do full processing
let image = sharp(imagePath);
const metadata = await image.metadata();
let effectiveQuality = quality;
// Resize if too large
if (metadata.width > maxWidth || metadata.height > maxHeight) {
if (needsResize) {
image = image.resize(maxWidth, maxHeight, {
fit: 'inside',
withoutEnlargement: true
@@ -182,18 +231,26 @@ class SecureImageService {
// Apply quality reduction for protection
if (protectionLevel === 'enhanced') {
quality = Math.min(quality, 70);
effectiveQuality = Math.min(quality, 70);
} else if (protectionLevel === 'maximum') {
quality = Math.min(quality, 60);
effectiveQuality = Math.min(quality, 60);
}
// Convert to appropriate format
image = image.jpeg({ quality, progressive: true });
// Preserve original format when possible, apply quality settings
const format = metadata.format || 'jpeg';
if (format === 'png' && !needsQualityReduction) {
image = image.png({ compressionLevel: 6 });
} else if (format === 'webp') {
image = image.webp({ quality: effectiveQuality });
} else {
// JPEG or when quality reduction is needed (convert to JPEG)
image = image.jpeg({ quality: effectiveQuality, progressive: true });
}
// Add invisible watermark/fingerprint
if (addFingerprint) {
const fingerprint = crypto.randomBytes(16).toString('hex');
// Embed fingerprint in metadata
image = image.withMetadata({
exif: {
+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
+212
View File
@@ -0,0 +1,212 @@
const axios = require('axios');
const fs = require('fs').promises;
const path = require('path');
const logger = require('../utils/logger');
// Cache for version info (avoid hitting GitHub API too often)
let versionCache = null;
let lastCheck = 0;
const CACHE_TTL = 60 * 60 * 1000; // 1 hour cache
/**
* Get current installed version from package.json
*/
async function getCurrentVersion() {
try {
const packagePath = path.join(__dirname, '../../package.json');
const packageContent = await fs.readFile(packagePath, 'utf8');
const packageJson = JSON.parse(packageContent);
return packageJson.version || '0.0.0';
} catch (err) {
logger.error('Could not read package.json for version:', err);
return '0.0.0';
}
}
/**
* Determine current release channel from version or environment
*/
function getCurrentChannel(version) {
// Check environment variable first
const envChannel = process.env.PICPEAK_RELEASE_CHANNEL;
if (envChannel && ['stable', 'beta'].includes(envChannel)) {
return envChannel;
}
// Infer from version string
if (version && version.includes('-beta')) {
return 'beta';
}
return 'stable';
}
/**
* Parse version string into comparable parts
*/
function parseVersion(version) {
if (!version) return null;
// Handle versions like "2.3.0" or "2.3.0-beta.1"
const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/);
if (!match) return null;
return {
major: parseInt(match[1], 10),
minor: parseInt(match[2], 10),
patch: parseInt(match[3], 10),
beta: match[4] ? parseInt(match[4], 10) : null,
isBeta: !!match[4]
};
}
/**
* Compare two versions
* Returns: 1 if a > b, -1 if a < b, 0 if equal
*/
function compareVersions(a, b) {
const va = parseVersion(a);
const vb = parseVersion(b);
if (!va || !vb) return 0;
// Compare major.minor.patch
if (va.major !== vb.major) return va.major > vb.major ? 1 : -1;
if (va.minor !== vb.minor) return va.minor > vb.minor ? 1 : -1;
if (va.patch !== vb.patch) return va.patch > vb.patch ? 1 : -1;
// Handle beta vs stable
if (va.isBeta && !vb.isBeta) return -1; // beta < stable
if (!va.isBeta && vb.isBeta) return 1; // stable > beta
// Both are beta - compare beta numbers
if (va.isBeta && vb.isBeta) {
if (va.beta !== vb.beta) return va.beta > vb.beta ? 1 : -1;
}
return 0;
}
/**
* Fetch available versions from GitHub Releases
* Uses GitHub Releases API which is publicly accessible without authentication
*/
async function fetchAvailableVersions() {
try {
// Use GitHub Releases API (public, no auth required)
const response = await axios.get(
'https://api.github.com/repos/the-luap/picpeak/releases',
{
headers: {
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
'User-Agent': 'PicPeak-Update-Checker'
},
timeout: 10000
}
);
// Extract version tags from releases
const versions = {
stable: [],
beta: []
};
for (const release of response.data) {
const tag = release.tag_name;
if (!tag) continue;
// Remove 'v' prefix if present
const version = tag.startsWith('v') ? tag.substring(1) : tag;
if (version.match(/^\d+\.\d+\.\d+$/)) {
// Stable version
versions.stable.push(version);
} else if (version.match(/^\d+\.\d+\.\d+-beta\.\d+$/)) {
// Beta version
versions.beta.push(version);
}
}
// Sort versions descending (newest first)
versions.stable.sort((a, b) => compareVersions(b, a));
versions.beta.sort((a, b) => compareVersions(b, a));
return versions;
} catch (error) {
logger.error('Failed to fetch available versions from GitHub:', error.message);
return null;
}
}
/**
* Check for available updates
*/
async function checkForUpdates(forceRefresh = false) {
const now = Date.now();
// Use cache if available and not expired
if (!forceRefresh && versionCache && (now - lastCheck) < CACHE_TTL) {
return versionCache;
}
const currentVersion = await getCurrentVersion();
const currentChannel = getCurrentChannel(currentVersion);
const availableVersions = await fetchAvailableVersions();
if (!availableVersions) {
return {
current: currentVersion,
channel: currentChannel,
updateAvailable: false,
error: 'Unable to check for updates'
};
}
// Determine latest version for current channel
const latestStable = availableVersions.stable[0] || currentVersion;
const latestBeta = availableVersions.beta[0] || currentVersion;
const latestForChannel = currentChannel === 'beta' ? latestBeta : latestStable;
const updateAvailable = compareVersions(latestForChannel, currentVersion) > 0;
// Also check if there's a newer beta for stable users who want to preview
const newerBetaAvailable = currentChannel === 'stable' &&
availableVersions.beta.length > 0 &&
compareVersions(latestBeta, currentVersion) > 0;
const result = {
current: currentVersion,
channel: currentChannel,
latest: {
stable: latestStable,
beta: latestBeta,
forChannel: latestForChannel
},
updateAvailable,
newerBetaAvailable,
lastChecked: new Date().toISOString()
};
// Update cache
versionCache = result;
lastCheck = now;
return result;
}
/**
* Clear the version cache (useful for testing)
*/
function clearCache() {
versionCache = null;
lastCheck = 0;
}
module.exports = {
checkForUpdates,
getCurrentVersion,
getCurrentChannel,
compareVersions,
parseVersion,
clearCache
};
@@ -0,0 +1,246 @@
/**
* Update Notification Service
* Checks for updates and sends email notifications to administrators.
*/
const { db } = require('../database/db');
const { checkForUpdates } = require('./updateCheckService');
const { sendTemplateEmail, initializeTransporter } = require('./emailProcessor');
const logger = require('../utils/logger');
/**
* Get update notification settings from database
*/
async function getUpdateNotificationSettings() {
try {
const settings = await db('app_settings')
.whereIn('setting_key', [
'update_email_notifications_enabled',
'update_email_recipients',
'last_notified_version'
])
.select('setting_key', 'setting_value');
const result = {};
for (const setting of settings) {
try {
result[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
result[setting.setting_key] = setting.setting_value;
}
}
return {
enabled: result.update_email_notifications_enabled === true,
recipients: result.update_email_recipients || '',
lastNotifiedVersion: result.last_notified_version || ''
};
} catch (error) {
logger.error('Error fetching update notification settings:', error);
return {
enabled: false,
recipients: '',
lastNotifiedVersion: ''
};
}
}
/**
* Update the last notified version in database
*/
async function updateLastNotifiedVersion(version) {
try {
await db('app_settings')
.where('setting_key', 'last_notified_version')
.update({
setting_value: JSON.stringify(version),
updated_at: db.fn.now()
});
} catch (error) {
logger.error('Error updating last notified version:', error);
}
}
/**
* Get admin email addresses to notify
* If recipients setting is empty, get all active admin emails
*/
async function getNotificationRecipients(recipientsSetting) {
try {
if (recipientsSetting && recipientsSetting.trim()) {
// Use configured recipients (comma-separated)
return recipientsSetting.split(',').map(email => email.trim()).filter(Boolean);
}
// Fallback: get all active admin user emails
const admins = await db('admin_users')
.where('is_active', true)
.whereNotNull('email')
.select('email');
return admins.map(admin => admin.email).filter(Boolean);
} catch (error) {
logger.error('Error fetching notification recipients:', error);
return [];
}
}
/**
* Check for updates and send notification emails if new version is available
*/
async function checkAndNotifyUpdates() {
logger.info('Update notification service: Checking for updates...');
try {
// Check if update notifications are enabled
const settings = await getUpdateNotificationSettings();
if (!settings.enabled) {
logger.info('Update email notifications are disabled');
return { notified: false, reason: 'notifications_disabled' };
}
// Check for available updates
const updateInfo = await checkForUpdates();
if (!updateInfo.updateAvailable) {
logger.info('No updates available');
return { notified: false, reason: 'no_updates' };
}
const newVersion = updateInfo.latest.forChannel;
// Check if we've already notified about this version
if (settings.lastNotifiedVersion === newVersion) {
logger.info(`Already notified about version ${newVersion}`);
return { notified: false, reason: 'already_notified' };
}
// Get recipients
const recipients = await getNotificationRecipients(settings.recipients);
if (recipients.length === 0) {
logger.warn('No recipients configured for update notifications');
return { notified: false, reason: 'no_recipients' };
}
// Ensure email transporter is initialized
await initializeTransporter();
// Send email to each recipient
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
const releaseNotesUrl = `https://github.com/the-luap/picpeak/releases/tag/v${newVersion}`;
const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable';
let successCount = 0;
let errorCount = 0;
for (const email of recipients) {
try {
await sendTemplateEmail(email, 'version_update_available', {
current_version: updateInfo.current,
new_version: newVersion,
channel: channelLabel,
release_notes_url: releaseNotesUrl,
admin_url: `${frontendUrl}/admin`
});
successCount++;
logger.info(`Update notification sent to ${email}`);
} catch (error) {
errorCount++;
logger.error(`Failed to send update notification to ${email}:`, error);
}
}
// Update last notified version
if (successCount > 0) {
await updateLastNotifiedVersion(newVersion);
logger.info(`Update notifications sent: ${successCount} success, ${errorCount} failed`);
}
return {
notified: successCount > 0,
newVersion,
successCount,
errorCount,
totalRecipients: recipients.length
};
} catch (error) {
logger.error('Error in update notification service:', error);
return { notified: false, reason: 'error', error: error.message };
}
}
/**
* Force send update notification (for manual trigger from admin UI)
*/
async function sendUpdateNotificationNow() {
logger.info('Manually triggering update notification...');
try {
// Check for available updates
const updateInfo = await checkForUpdates(true); // Force refresh
if (!updateInfo.updateAvailable) {
return { success: false, message: 'No updates available' };
}
const newVersion = updateInfo.latest.forChannel;
const settings = await getUpdateNotificationSettings();
// Get recipients
const recipients = await getNotificationRecipients(settings.recipients);
if (recipients.length === 0) {
return { success: false, message: 'No recipients configured' };
}
// Ensure email transporter is initialized
await initializeTransporter();
// Send email to each recipient
const releaseNotesUrl = `https://github.com/the-luap/picpeak/releases/tag/v${newVersion}`;
const channelLabel = updateInfo.channel === 'beta' ? 'Beta' : 'Stable';
let successCount = 0;
let errorCount = 0;
for (const email of recipients) {
try {
await sendTemplateEmail(email, 'version_update_available', {
current_version: updateInfo.current,
new_version: newVersion,
channel: channelLabel,
release_notes_url: releaseNotesUrl
});
successCount++;
} catch (error) {
errorCount++;
logger.error(`Failed to send update notification to ${email}:`, error);
}
}
// Update last notified version
if (successCount > 0) {
await updateLastNotifiedVersion(newVersion);
}
return {
success: successCount > 0,
newVersion,
successCount,
errorCount,
totalRecipients: recipients.length
};
} catch (error) {
logger.error('Error sending manual update notification:', error);
return { success: false, message: error.message };
}
}
module.exports = {
checkAndNotifyUpdates,
sendUpdateNotificationNow,
getUpdateNotificationSettings,
getNotificationRecipients
};
+87 -1
View File
@@ -7,6 +7,25 @@ const CACHE_TTL_MS = 60_000;
let cachedValue = DEFAULT_MAX_FILES_PER_UPLOAD;
let cacheExpiresAt = 0;
// Map of file extension to MIME type(s)
const EXTENSION_TO_MIME = {
'jpg': 'image/jpeg',
'jpeg': 'image/jpeg',
'png': 'image/png',
'webp': 'image/webp',
'gif': 'image/gif',
'mp4': 'video/mp4',
'm4v': 'video/mp4',
'webm': 'video/webm',
'mov': 'video/quicktime',
'avi': 'video/x-msvideo',
};
const DEFAULT_ALLOWED_FILE_TYPES = 'jpg,jpeg,png,webp';
let cachedAllowedTypes = null;
let allowedTypesCacheExpiresAt = 0;
const parseSettingValue = (setting) => {
if (!setting || setting.setting_value == null) {
return null;
@@ -79,9 +98,76 @@ const clearMaxFilesPerUploadCache = () => {
cacheExpiresAt = 0;
};
/**
* Convert a comma-separated list of file extensions into an array of MIME types.
* Unknown extensions are silently ignored.
*/
const extensionsToMimeTypes = (extString) => {
if (!extString || typeof extString !== 'string') {
return extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES);
}
const mimeSet = new Set();
extString.split(',').forEach(ext => {
const cleaned = ext.trim().toLowerCase().replace(/^\./, '');
const mime = EXTENSION_TO_MIME[cleaned];
if (mime) {
mimeSet.add(mime);
}
});
if (mimeSet.size === 0) {
return extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES);
}
return Array.from(mimeSet);
};
/**
* Get the allowed MIME types for uploads from the database setting.
* Returns an array of MIME type strings, e.g. ['image/jpeg', 'image/png', 'video/mp4'].
*/
const getAllowedMimeTypes = async () => {
if (Date.now() < allowedTypesCacheExpiresAt && cachedAllowedTypes) {
return cachedAllowedTypes;
}
try {
const setting = await db('app_settings')
.where({ setting_key: 'general_allowed_file_types' })
.first();
let rawValue = setting?.setting_value;
if (typeof rawValue === 'string') {
try { rawValue = JSON.parse(rawValue); } catch { /* keep string */ }
}
const mimeTypes = extensionsToMimeTypes(rawValue);
cachedAllowedTypes = mimeTypes;
allowedTypesCacheExpiresAt = Date.now() + CACHE_TTL_MS;
return mimeTypes;
} catch (error) {
console.error('Failed to read allowed file types setting:', error.message);
const fallback = extensionsToMimeTypes(DEFAULT_ALLOWED_FILE_TYPES);
cachedAllowedTypes = fallback;
allowedTypesCacheExpiresAt = Date.now() + CACHE_TTL_MS;
return fallback;
}
};
const clearAllowedTypesCache = () => {
allowedTypesCacheExpiresAt = 0;
cachedAllowedTypes = null;
};
module.exports = {
getMaxFilesPerUpload,
clearMaxFilesPerUploadCache,
getAllowedMimeTypes,
clearAllowedTypesCache,
extensionsToMimeTypes,
EXTENSION_TO_MIME,
DEFAULT_MAX_FILES_PER_UPLOAD,
MAX_ALLOWED_FILES_PER_UPLOAD
MAX_ALLOWED_FILES_PER_UPLOAD,
DEFAULT_ALLOWED_FILE_TYPES
};
+36 -3
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
@@ -60,7 +65,7 @@ async function createInvitation({ email, roleId, invitedById }) {
// Queue invitation email
const frontendUrl = process.env.FRONTEND_URL || process.env.ADMIN_URL || 'http://localhost:3005';
await queueEmail(null, email, 'admin_invitation', {
invite_link: `${frontendUrl}/admin/accept-invite/${token}`,
invite_link: `${frontendUrl}/invite/${token}`,
role_name: role.display_name,
expires_at: expiresAt.toISOString()
});
@@ -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;
}
@@ -0,0 +1,433 @@
/**
* WatermarkGeneratorService
*
* Handles batch generation of pre-watermarked images for fast serving.
* This service is responsible for:
* - Generating watermarks for newly uploaded photos
* - Regenerating all watermarks when settings change
* - Tracking regeneration progress
*/
const path = require('path');
const { db } = require('../database/db');
const watermarkService = require('./watermarkService');
const { getStoragePath } = require('../config/storage');
class WatermarkGeneratorService {
constructor() {
// Track active regeneration jobs
this.activeJobs = new Map();
// Batch size for processing (to manage memory)
this.batchSize = 10;
// Concurrent processing limit
this.concurrentLimit = 2;
}
/**
* Generate watermark for a single photo
* @param {number} photoId - The photo ID
* @returns {Object} Result with success status and watermark path
*/
async generateForPhoto(photoId) {
try {
// Get photo with event info
const photo = await db('photos')
.join('events', 'photos.event_id', 'events.id')
.where('photos.id', photoId)
.select(
'photos.*',
'events.slug',
'events.source_mode',
'events.external_path'
)
.first();
if (!photo) {
return { success: false, error: 'Photo not found' };
}
// Skip video files
if (photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'))) {
return { success: false, error: 'Videos do not support watermarks' };
}
// Get watermark settings
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
return { success: false, error: 'Watermarking is disabled' };
}
// Resolve the original file path
const originalPath = this.resolvePhotoPath(photo);
if (!originalPath) {
return { success: false, error: 'Could not resolve photo path' };
}
// Generate and save watermark
const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
if (result.success) {
// Update database with watermark path
await db('photos')
.where({ id: photoId })
.update({
watermark_path: result.watermarkPath,
watermark_generated_at: db.fn.now()
});
}
return result;
} catch (error) {
console.error(`Error generating watermark for photo ${photoId}:`, error);
return { success: false, error: error.message };
}
}
/**
* Resolve the full file path for a photo
*/
resolvePhotoPath(photo) {
const storagePath = getStoragePath();
// Handle external/reference mode
if (photo.source_mode === 'reference' && photo.external_relpath) {
const externalRoot = process.env.EXTERNAL_MEDIA_PATH || path.join(storagePath, 'external');
return path.join(externalRoot, photo.external_path || '', photo.external_relpath);
}
// Standard managed mode
if (photo.file_path) {
// file_path might be absolute or relative
if (path.isAbsolute(photo.file_path)) {
return photo.file_path;
}
return path.join(storagePath, photo.file_path);
}
// Fallback to constructing path from slug and filename
return path.join(storagePath, 'events', 'active', photo.slug, photo.filename);
}
/**
* Generate watermarks for all photos in an event
* @param {number} eventId - The event ID
* @param {Function} onProgress - Optional callback for progress updates
* @returns {Object} Result with success count and errors
*/
async generateForEvent(eventId, onProgress = null) {
const results = { total: 0, success: 0, failed: 0, errors: [] };
try {
// Get all photos for the event (excluding videos)
const photos = await db('photos')
.join('events', 'photos.event_id', 'events.id')
.where('photos.event_id', eventId)
.whereNot(function() {
this.where('photos.media_type', 'video')
.orWhere('photos.mime_type', 'like', 'video/%');
})
.select(
'photos.*',
'events.slug',
'events.source_mode',
'events.external_path'
);
results.total = photos.length;
if (photos.length === 0) {
return results;
}
// Get watermark settings once
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
return { ...results, errors: ['Watermarking is disabled'] };
}
// Process in batches
for (let i = 0; i < photos.length; i += this.batchSize) {
const batch = photos.slice(i, i + this.batchSize);
// Process batch with limited concurrency
const batchResults = await Promise.all(
batch.map(photo => this.processPhotoWatermark(photo, settings))
);
// Collect results
for (const result of batchResults) {
if (result.success) {
results.success++;
} else {
results.failed++;
if (result.error) {
results.errors.push(`Photo ${result.photoId}: ${result.error}`);
}
}
}
// Progress callback
if (onProgress) {
onProgress({
total: results.total,
processed: results.success + results.failed,
success: results.success,
failed: results.failed
});
}
}
return results;
} catch (error) {
console.error(`Error generating watermarks for event ${eventId}:`, error);
return { ...results, errors: [...results.errors, error.message] };
}
}
/**
* Process watermark for a single photo (internal helper)
*/
async processPhotoWatermark(photo, settings) {
try {
const originalPath = this.resolvePhotoPath(photo);
if (!originalPath) {
return { success: false, photoId: photo.id, error: 'Could not resolve path' };
}
const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
if (result.success) {
await db('photos')
.where({ id: photo.id })
.update({
watermark_path: result.watermarkPath,
watermark_generated_at: db.fn.now()
});
}
return { ...result, photoId: photo.id };
} catch (error) {
return { success: false, photoId: photo.id, error: error.message };
}
}
/**
* Regenerate watermarks for all photos in the system
* @param {Function} onProgress - Optional callback for progress updates
* @returns {Object} Result with success count and errors
*/
async regenerateAll(onProgress = null) {
const jobId = Date.now().toString();
const results = { jobId, total: 0, success: 0, failed: 0, errors: [], status: 'running' };
try {
this.activeJobs.set(jobId, results);
// Get watermark settings
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
results.status = 'completed';
results.errors.push('Watermarking is disabled');
return results;
}
// First, clear existing watermarks from DB (the files will be overwritten)
// This ensures stale paths don't persist if regeneration fails
// Get all image photos (exclude videos)
const photos = await db('photos')
.join('events', 'photos.event_id', 'events.id')
.whereNot(function() {
this.where('photos.media_type', 'video')
.orWhere('photos.mime_type', 'like', 'video/%');
})
.select(
'photos.*',
'events.slug',
'events.source_mode',
'events.external_path'
);
results.total = photos.length;
if (photos.length === 0) {
results.status = 'completed';
return results;
}
console.log(`Starting watermark regeneration for ${photos.length} photos`);
// Process in batches
for (let i = 0; i < photos.length; i += this.batchSize) {
// Check if job was cancelled
if (!this.activeJobs.has(jobId)) {
results.status = 'cancelled';
return results;
}
const batch = photos.slice(i, i + this.batchSize);
// Process batch with limited concurrency
const batchResults = await Promise.all(
batch.map(photo => this.processPhotoWatermark(photo, settings))
);
// Collect results
for (const result of batchResults) {
if (result.success) {
results.success++;
} else {
results.failed++;
if (result.error && results.errors.length < 50) {
results.errors.push(`Photo ${result.photoId}: ${result.error}`);
}
}
}
// Update job status
this.activeJobs.set(jobId, { ...results });
// Progress callback
if (onProgress) {
onProgress({
jobId,
total: results.total,
processed: results.success + results.failed,
success: results.success,
failed: results.failed,
percentComplete: Math.round(((results.success + results.failed) / results.total) * 100)
});
}
// Small delay between batches to prevent CPU saturation
await new Promise(resolve => setTimeout(resolve, 100));
}
results.status = 'completed';
console.log(`Watermark regeneration completed: ${results.success}/${results.total} successful`);
return results;
} catch (error) {
console.error('Error during watermark regeneration:', error);
results.status = 'failed';
results.errors.push(error.message);
return results;
} finally {
// Clean up job tracking after a delay
setTimeout(() => {
this.activeJobs.delete(jobId);
}, 60000); // Keep for 1 minute for status queries
}
}
/**
* Clear all watermarks (when watermarking is disabled)
*/
async clearAllWatermarks() {
try {
// Get all photos with watermarks
const photos = await db('photos')
.whereNotNull('watermark_path')
.select('id', 'watermark_path');
// Delete watermark files
for (const photo of photos) {
await watermarkService.deleteWatermarkFile(photo.watermark_path);
}
// Clear database paths
await db('photos')
.whereNotNull('watermark_path')
.update({
watermark_path: null,
watermark_generated_at: null
});
console.log(`Cleared ${photos.length} watermarks`);
return { success: true, cleared: photos.length };
} catch (error) {
console.error('Error clearing watermarks:', error);
return { success: false, error: error.message };
}
}
/**
* Delete watermark for a specific photo
*/
async deleteForPhoto(photoId) {
try {
const photo = await db('photos')
.where({ id: photoId })
.select('watermark_path')
.first();
if (photo && photo.watermark_path) {
await watermarkService.deleteWatermarkFile(photo.watermark_path);
await db('photos')
.where({ id: photoId })
.update({
watermark_path: null,
watermark_generated_at: null
});
}
return { success: true };
} catch (error) {
console.error(`Error deleting watermark for photo ${photoId}:`, error);
return { success: false, error: error.message };
}
}
/**
* Get status of an active regeneration job
*/
getJobStatus(jobId) {
return this.activeJobs.get(jobId) || null;
}
/**
* Cancel an active regeneration job
*/
cancelJob(jobId) {
if (this.activeJobs.has(jobId)) {
this.activeJobs.delete(jobId);
return true;
}
return false;
}
/**
* Check if there's an active regeneration job
*/
hasActiveJob() {
for (const [, job] of this.activeJobs) {
if (job.status === 'running') {
return true;
}
}
return false;
}
/**
* Get count of photos needing watermark generation
*/
async getPendingCount() {
const settings = await watermarkService.getWatermarkSettings();
if (!settings || !settings.enabled) {
return 0;
}
const result = await db('photos')
.whereNull('watermark_path')
.whereNot(function() {
this.where('media_type', 'video')
.orWhere('mime_type', 'like', 'video/%');
})
.count('id as count')
.first();
return parseInt(result.count) || 0;
}
}
module.exports = new WatermarkGeneratorService();
+139 -8
View File
@@ -2,6 +2,7 @@ const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { getStoragePath } = require('../config/storage');
class WatermarkService {
constructor() {
@@ -177,14 +178,25 @@ class WatermarkService {
settings.position
);
// Apply watermark
const watermarkedBuffer = await image
.composite([{
input: watermarkBuffer,
top: position.top,
left: position.left
}])
.toBuffer();
// Apply watermark with high quality output to preserve original image quality
let watermarkedImage = image.composite([{
input: watermarkBuffer,
top: position.top,
left: position.left
}]);
// Preserve original format with high quality settings
const format = metadata.format || 'jpeg';
let watermarkedBuffer;
if (format === 'png') {
watermarkedBuffer = await watermarkedImage.png({ quality: 100, compressionLevel: 6 }).toBuffer();
} else if (format === 'webp') {
watermarkedBuffer = await watermarkedImage.webp({ quality: 95, lossless: false }).toBuffer();
} else {
// Default to JPEG with maximum quality (100) to prevent recompression
watermarkedBuffer = await watermarkedImage.jpeg({ quality: 100, mozjpeg: true }).toBuffer();
}
// Cache the result
this.cache.set(cacheKey, {
@@ -221,6 +233,125 @@ class WatermarkService {
clearCache() {
this.cache.clear();
}
/**
* Get the watermarks directory path, creating it if needed
*/
async getWatermarksDir() {
const watermarksDir = path.join(getStoragePath(), 'watermarks');
try {
await fs.access(watermarksDir);
} catch {
await fs.mkdir(watermarksDir, { recursive: true });
}
return watermarksDir;
}
/**
* Get the file extension from a filename
*/
getFileExtension(filename) {
const ext = path.extname(filename).toLowerCase();
// Map common extensions
if (ext === '.jpeg') return '.jpg';
return ext || '.jpg';
}
/**
* Generate watermarked version of a photo and save to disk
* @param {Object} photo - Photo object with id, filename, and path info
* @param {string} originalPath - Full path to the original image file
* @param {Object} settings - Watermark settings (optional, will fetch if not provided)
* @returns {Object} { success, watermarkPath, error }
*/
async generateAndSaveWatermark(photo, originalPath, settings = null) {
try {
// Get settings if not provided
if (!settings) {
settings = await this.getWatermarkSettings();
}
// If watermarking is disabled, return early
if (!settings || !settings.enabled) {
return { success: false, watermarkPath: null, error: 'Watermarking is disabled' };
}
// Verify original file exists
try {
await fs.access(originalPath);
} catch {
return { success: false, watermarkPath: null, error: 'Original file not found' };
}
// Generate watermarked buffer using existing method
const watermarkedBuffer = await this.applyWatermark(originalPath, settings);
// Determine output path
const watermarksDir = await this.getWatermarksDir();
const ext = this.getFileExtension(photo.filename);
const outputFilename = `${photo.id}_watermarked${ext}`;
const outputPath = path.join(watermarksDir, outputFilename);
// Write the watermarked image to disk
await fs.writeFile(outputPath, watermarkedBuffer);
// Return relative path for database storage
const relativePath = `watermarks/${outputFilename}`;
return {
success: true,
watermarkPath: relativePath,
error: null
};
} catch (error) {
console.error(`Error generating watermark for photo ${photo.id}:`, error);
return {
success: false,
watermarkPath: null,
error: error.message
};
}
}
/**
* Delete a pre-generated watermark file
* @param {string} watermarkPath - Relative path to the watermark file
* @returns {boolean} - True if deleted successfully
*/
async deleteWatermarkFile(watermarkPath) {
if (!watermarkPath) return false;
try {
const fullPath = path.join(getStoragePath(), watermarkPath);
await fs.unlink(fullPath);
return true;
} catch (error) {
// File might not exist, which is fine
if (error.code !== 'ENOENT') {
console.error('Error deleting watermark file:', error);
}
return false;
}
}
/**
* Create a hash of current watermark settings for change detection
* @returns {string} - Hash string of settings
*/
async getSettingsHash() {
const settings = await this.getWatermarkSettings();
if (!settings) return '';
const hashData = `${settings.enabled}-${settings.logoPath || ''}-${settings.position}-${settings.opacity}-${settings.size}`;
// Simple hash for change detection (not cryptographic)
let hash = 0;
for (let i = 0; i < hashData.length; i++) {
const char = hashData.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // Convert to 32bit integer
}
return hash.toString(16);
}
}
module.exports = new WatermarkService();
+1 -1
View File
@@ -76,7 +76,7 @@ class XmpGenerator {
* @returns {string} Description XML
*/
generateDescription(photo) {
const rating = photo.average_rating ? photo.average_rating.toFixed(1) : '0';
const rating = photo.average_rating ? parseFloat(photo.average_rating).toFixed(1) : '0';
const likes = photo.like_count || 0;
const favorites = photo.favorite_count || 0;
+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) => {

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