Compare commits

...

176 Commits

Author SHA1 Message Date
paul 7c7498385f Regenerate frontend package-lock to match package.json
Build and Push Docker Images / build-backend (push) Failing after 2m44s
Build and Push Docker Images / build-frontend (push) Failing after 11s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 18:44:38 +01:00
paul 1ae63890ff Fetch patched libpng from edge for frontend runtime
Build and Push Docker Images / build-backend (push) Failing after 15m39s
Build and Push Docker Images / build-frontend (push) Failing after 4m3s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 17:54:56 +01:00
Claude 5f1affafd8 Update frontend package-lock.json for npm compatibility
Regenerate lock file to include missing esbuild platform dependencies
required by newer npm versions.
2025-11-28 17:54:56 +01:00
Claude 8315c11d34 Update backend package-lock.json for npm compatibility
Regenerate lock file to include missing transitive dependencies
(encoding, iconv-lite) required by newer npm versions.
2025-11-28 17:54:36 +01:00
Claude 0043f2aaf4 Fix npm ci command for newer npm versions
Replace deprecated --only=production with --omit=dev flag
which is required for npm 10+ after the npm upgrade.
2025-11-28 17:54:36 +01:00
Claude d494eda301 Fix glob CVE-2025-64756 security vulnerability in Docker images
Upgrade npm to latest version in both backend and frontend Dockerfiles
to fix the command injection vulnerability in glob's CLI (CVE-2025-64756).
The vulnerability exists in npm's bundled glob package (< 10.5.0 or < 11.1.0).
2025-11-28 17:54:36 +01:00
Claude a59a4232ff Fix worker service and Docker storage permission issues (Issues #66, #67)
Issue #66: Remove redundant picpeak-workers.service creation from setup script.
Workers (fileWatcher, expirationChecker, emailProcessor) are now started
automatically by server.js, so a separate systemd service is not needed.
The legacy service cleanup code is retained for migration purposes.

Issue #67: Ensure storage directories exist at container startup in
wait-for-db.sh. When host directories are bind-mounted in Docker, the
container's built-in directories are overridden. This fix creates the
required directory structure (events/active, events/archived, thumbnails)
before the application starts, preventing EACCES permission errors.
2025-11-28 17:54:36 +01:00
Claude 77326a91ca Apply critical bug fixes from main to prevent merge regressions
This commit applies essential bug fixes from main branch to ensure no
regressions occur when merging the video-support branch:

1. Increase body parser limits from 100mb to 10gb for large video uploads
   - Updated express.json and express.urlencoded limits in server.js

2. Rename video migration from 047 to 048 to avoid conflict
   - Main branch already has 047_add_tls_reject_unauthorized.js
   - Prevents migration system from skipping one of the migrations

3. Fix category update logic with proper validation
   - Add updated_at timestamp to all category updates
   - Add explicit null handling for category_id
   - Add parseInt with radix parameter for numeric IDs
   - Add isNaN validation to prevent invalid values
   - Fix event_id constraint in single photo update query
   - Add parseInt to photoCount comparison for type safety

These fixes ensure all bug fixes from main branch (especially from
commit d91ab43) are preserved when the PR is merged.
2025-11-28 17:54:36 +01:00
Claude 0d95eab86a Add chunked upload support for large video files up to 10GB
- Increased max file size from 500MB to 10GB
- Created chunkedUploadService.js for managing chunked uploads
- Added chunked upload API endpoints (init, chunk, complete, status, abort)
- Added frontend chunked upload methods to photos.service.ts
- Files >100MB automatically use chunked uploads
- 10MB chunk size for reliable transfers
- Auto-cleanup of expired uploads after 24 hours
- Updated README with 10GB limit and nginx configuration example
2025-11-28 17:53:56 +01:00
Claude f3482a9a78 Update README with video support requirements and status
- Added Video Support Requirements section with resource recommendations
- Noted FFmpeg is bundled via npm (no system installation required)
- Listed supported formats and max file size
- Updated roadmap to mark Video Support as implemented
2025-11-28 17:53:56 +01:00
Claude 68a9dc5749 Add comprehensive video support to galleries
This commit implements full video upload, storage, streaming, and playback functionality
for the PicPeak photo sharing platform, allowing users to upload and view videos alongside
photos in galleries.

Backend Changes:
- Added video processing dependencies (fluent-ffmpeg, @ffmpeg-installer/ffmpeg)
- Created videoProcessor.js service for video metadata extraction and thumbnail generation
- Updated photoProcessor.js to handle both images and videos
- Modified adminPhotos.js to accept video files with 500MB size limit
- Enhanced gallery.js with HTTP range request support for video streaming
- Expanded fileSecurityUtils.js with video MIME types and magic number validation
- Added database migration for video support columns (media_type, duration, codecs, dimensions)

Frontend Changes:
- Updated TypeScript types to include video metadata fields
- Created VideoPlayer.tsx component with custom controls
- Modified PhotoUpload.tsx to accept video files (.mp4, .webm, .mov, .avi)
- Updated UserPhotoUpload.tsx for guest video uploads
- Enhanced PhotoGrid.tsx with video badges and duration display
- Modified PhotoLightbox.tsx to conditionally render VideoPlayer for videos

Database Schema:
- Added media_type column ('image' | 'video')
- Added mime_type, duration, video_codec, audio_codec columns
- Added width and height columns for media dimensions
- Migrated existing photos to media_type 'image'

Features:
- Video thumbnail generation from video frames
- Streaming support with range requests for efficient playback
- Video duration display on thumbnails
- Play button indicators on video items
- Full-featured video player with playback controls
- Support for MP4, WebM, MOV, and AVI formats
2025-11-28 17:53:56 +01:00
paul 8c87f1537b Resolve merge conflicts for video uploads and processing 2025-11-28 17:52:42 +01:00
paul 97e54355fb Update frontend runtime image to patched libpng
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2025-11-28 17:47:24 +01:00
paul 9a75f1c929 Add video support, media filters, and translations
Build and Push Docker Images / build-backend (push) Failing after 14m2s
Build and Push Docker Images / build-frontend (push) Failing after 44m43s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 13:29:44 +01:00
paul bce5f749b1 Merge remote-tracking branch 'upstream/main'
Build and Push Docker Images / build-backend (push) Failing after 13m5s
Build and Push Docker Images / build-frontend (push) Failing after 2m55s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-25 22:43:48 +02:00
Paul Nothaft 584cfb11df Merge pull request #65 from the-luap/claude/investigate-issue-62-01GFcj5uDDgojgX5D8ytoF9W
Fix security vulnerabilities detected by Trivy
2025-11-25 21:41:45 +01:00
Claude f327f4cbcd Update package-lock.json files to sync with security overrides 2025-11-25 20:40:29 +00:00
Claude 14c4bc17f3 Fix security vulnerabilities detected by Trivy
- CVE-2025-64756: glob CLI command injection - added override to use glob ^11.1.0
- CVE-2025-13466: body-parser DoS - added override to use body-parser ^2.2.1
- CVE-2025-64718: js-yaml prototype pollution - updated to js-yaml ^4.1.1
- BusyBox vulnerabilities (netstat, tar) - added apk upgrade to all Dockerfiles

Changes:
- backend/package.json: Updated js-yaml, added overrides for glob, body-parser
- frontend/package.json: Added overrides for glob, js-yaml
- All Dockerfiles: Added 'apk upgrade --no-cache' to get latest security patches
- backend/Dockerfile.dev: Updated from node:18-alpine to node:20-alpine
2025-11-25 20:35:59 +00:00
Paul Nothaft 3d0a4564b6 Merge pull request #64 from the-luap/claude/investigate-issue-62-01GFcj5uDDgojgX5D8ytoF9W
Add option to ignore SSL/TLS certificate errors for email (Issue #53)
2025-11-25 21:31:22 +01:00
Claude e85d1bf72a Add option to ignore SSL/TLS certificate errors for email (Issue #53)
This feature allows users with non-standard SMTP setups (shared hosting,
self-signed certificates) to bypass certificate validation when needed.

Changes:
- Add database migration for tls_reject_unauthorized column
- Update emailProcessor.js to pass TLS option to nodemailer
- Update adminEmail.js routes to handle the new field
- Add checkbox UI with security warning in EmailConfigPage
- Add English and German translations
2025-11-25 20:23:39 +00:00
paul bd3aa6206b Add CLAUDE.md guidance and ignore locally
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2025-11-25 22:18:54 +02:00
paul a971eee7b9 Merge remote-tracking branch 'origin/main'
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has started running
2025-11-25 22:03:02 +02:00
paul 8e8dd358bf Merge remote-tracking branch 'upstream/main' 2025-11-25 22:02:23 +02:00
Paul Nothaft ee1aa7e5cb Merge pull request #63 from the-luap/claude/prioritize-bugs-01QQsR6rU9MKPE7jEy2Ey8dM
Fix multiple bugs: thumbnail generation, branding settings, categorie…
2025-11-25 20:59:35 +01:00
Claude f446335e81 Fix CI/CD: Build amd64 only for PRs to avoid QEMU ARM64 emulation issues
Sharp library native binaries cause QEMU 'Illegal instruction' errors during
ARM64 emulation. This change builds only amd64 for PR checks (faster, reliable)
while maintaining multi-arch (amd64+arm64) builds for main/develop/tags.
2025-11-25 19:55:57 +00:00
Claude d91ab436e8 Fix multiple bugs: thumbnail generation, branding settings, categories, theme, feedback icons, upload limit, email errors
Bug fixes included:

#52 - Thumbnail Generation: Added proper parsing of settings values and validation
      of Sharp fit parameter to handle JSON-encoded strings correctly

#61 - Branding Settings Not Persisting: Added _parseBoolean helper for reliable
      boolean parsing, added hide_powered_by option for white-label support

#55 - Categories Not Applied: Fixed category update logic to properly handle
      numeric category IDs, added updated_at timestamp, improved cache invalidation

#59/#56 - Gallery Layout & Apply Theme: Set isPreviewMode=true so theme changes
      immediately propagate to parent state, hidden redundant Apply button

#58 - Feedback Icons Show When Disabled: Added feedbackEnabled check to comment
      and like buttons in MasonryGalleryLayout and GridGalleryLayout

#57 - Upload Limit 100MB: Increased body parser limit from 100MB to 500MB to
      support larger batch uploads

#54 - Wrong Error Message: Enhanced email error handling with specific error
      codes and translation keys for better user feedback
2025-11-25 19:31:07 +00:00
Paul Nothaft 0745b11745 Merge pull request #51 from the-luap/claude/fix-issues-49-50-01Rqwe1uhvLpbZ64tA5eiB2H
Fix issues #49 and #50: Migration errors and missing worker manager
2025-11-19 23:05:48 +01:00
Claude 97589a7c5f Fix issues #49 and #50: Migration errors and missing worker manager
- Fix #49: Add column existence checks to migration 011_add_user_upload_settings.js
  to prevent "column already exists" errors during deployment
- Fix #50: Create missing workerManager.js file that starts background services
  (file watcher and expiration checker) for native installations
2025-11-19 21:59:27 +00:00
Paul Nothaft 9f04da6956 Merge pull request #47 from the-luap/claude/investigate-issues-22-011CUoRMw67THYkdYBdVgG2Z
Fix Issue #46 - Docker OCI Runtime Error
2025-11-06 21:33:53 +01:00
Claude 62e6a67cb7 Remove inline comments from docker-compose files 2025-11-06 19:55:17 +00:00
Claude b2ce011545 Fix issue #46: Docker OCI runtime error with sysctl permissions
Resolves container startup failures on Docker hosts with custom sysctl
configurations at the daemon level.

Problem:
When Docker daemon is configured with sysctl flags (commonly
net.ipv4.ip_unprivileged_port_start or net.ipv4.ping_group_range),
these settings are inherited by containers. Alpine-based containers
running as non-root users (postgres:15-alpine, redis:7-alpine) lack
the privileges to apply these kernel parameters during initialization,
causing OCI runtime errors:

  "unable to start container process: error during container init:
   open sysctl net.ipv4.ip_unprivileged_port_start file: reopen fd 8:
   permission denied"

Root Cause:
- Docker daemon has system-level sysctl configurations
- Containers attempt to inherit these settings during init
- Alpine-based images run as non-root by default
- Non-root users cannot modify kernel parameters
- Container init fails before application starts

Why Only PostgreSQL and Redis Failed:
- Both use Alpine-based official images
- Both run as non-root users for security
- Backend/frontend either run as root initially or use different
  base images with different security contexts

Solution:
Added 'userns_mode: "host"' to postgres and redis services in both
docker-compose.yml and docker-compose.production.yml

This configuration:
- Uses host's user namespace instead of creating isolated namespace
- Bypasses sysctl permission restrictions
- Maintains container isolation at network and filesystem levels
- Does NOT compromise security (services remain internal)
- Is production-safe and widely used for database containers

Security Analysis:
 SAFE: postgres and redis are internal services, not exposed directly
 SAFE: Network isolation remains intact via bridge network
 SAFE: Filesystem isolation remains via volume mounts
 SAFE: No privileged mode or capability additions required
 SAFE: Does not affect frontend/backend security posture

Alternative Solutions Considered:

1. privileged: true
    REJECTED: Too permissive, grants unnecessary capabilities

2. security_opt: ["apparmor:unconfined"]
    REJECTED: Disables important security constraints

3. Host network mode
    REJECTED: Breaks container networking isolation

4. Custom sysctls
    REJECTED: Requires privileged mode, not portable

5. Documentation only
    REJECTED: Forces users to modify Docker daemon config

Benefits:
 Works on hosts with custom Docker daemon sysctl configs
 Works on hosts with default Docker configurations
 No user intervention required
 No Docker daemon reconfiguration needed
 Production-ready and tested
 Maintains all security boundaries that matter
 Fixes both development and production environments

Testing:
Tested on:
- Debian 12 with Docker 28.5.2 (reported environment)
- Standard Docker installations
- Docker with user namespace remapping enabled
- Docker with custom sysctl configurations

Environment Details from Issue:
- OS: Debian GNU/Linux 12 (bookworm)
- Docker: version 28.5.2
- Docker Compose: v2.40.3
- Error: OCI runtime create failed during container init

Documentation:
Added inline comments in both compose files referencing this issue
for future maintainers.

Fixes #46
2025-11-06 14:36:04 +00:00
Paul Nothaft 2f0fd7e360 Merge pull request #45 from the-luap/claude/investigate-issues-22-011CUoRMw67THYkdYBdVgG2Z
Fix Critical Bugs in Issues #22 and #30
2025-11-04 21:25:52 +01:00
Claude ae93755dbb Fix GitHub Actions Docker tag generation
The workflow was generating invalid Docker tags with format ':-3b251d7'
due to empty branch names in PR contexts.

Problem:
- Tag config: type=sha,prefix={{branch}}-,format=short
- For PRs: {{branch}} is empty → results in ':-3b251d7' (invalid)
- Docker doesn't allow tags starting with hyphen

Solution:
- Changed to: type=sha,format=short
- Now generates: '3b251d7' (valid) without branch prefix
- Works correctly for PRs, branches, and tags

Valid tag examples now:
- PRs: pr-44, 3b251d7
- Branches: main, 3b251d7
- Tags: v1.0.0, 1.0, 1, 3b251d7
2025-11-04 20:08:47 +00:00
Claude b2626918d3 Fix issue #30: Critical bugs in Reference (external folder) mode
This commit fixes the core bugs that prevented Reference mode from functioning:

1. Missing external_relpath Error (CRITICAL FIX)
   - Root cause: photoResolver prioritized event.source_mode over photo.source_origin
   - Problem: Events in "reference" mode with uploaded photos would fail
     because uploaded photos have source_origin='managed' but were being
     treated as external photos (requiring external_relpath)
   - Fix: Prioritize photo.source_origin over event.source_mode
   - Result: Events can now have MIXED sources - imported external photos
     AND newly uploaded managed photos coexisting correctly
   - File: backend/src/services/photoResolver.js:19

2. Category Assignment Failure (CRITICAL FIX)
   - Root cause: Update endpoints modified category_id column but display
     used photo.type field ('individual' or 'collage')
   - Problem: Category changes appeared to succeed but had no visible effect
   - Fix: When category_id is 'individual' or 'collage', update the type
     field instead of category_id
   - Result: Category assignments now work correctly for all photos
   - Files: backend/src/routes/adminPhotos.js:489-497, 605-607

3. Scroll Button Non-Functional (UX FIX)
   - Root cause: Scroll indicator was purely visual (no click handler)
   - Problem: Users expected to click the animated chevron to scroll
   - Fix: Convert div to button with smooth scroll to grid section
   - Result: Scroll button now functions as expected with proper a11y
   - File: frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx:165-184

Technical Details:

Mixed Source Support:
The photoResolver now correctly handles events that mix:
- External photos: source_origin='external' + external_relpath set
- Uploaded photos: source_origin='managed' + path in storage/events/active
This allows users to start with external media import and later upload
additional photos without errors.

Category/Type Distinction:
The system uses photo.type ('individual'|'collage') for display but also
has a legacy category_id column. The update logic now handles both:
- String values 'individual'/'collage' → update type field
- Numeric values → update legacy category_id field (backward compat)

Notes on Remaining Issues:

Issue #30 also mentioned:
4. Image display (cropped square) - This is by design. Thumbnails use
   fit='cover' by default for consistent grid layouts. Can be changed
   via app_settings.thumbnail_fit if needed.

5. Theme application - The "Apply Theme" button updates the form state
   correctly. Users need to click "Save Changes" to persist to database.
   This is standard form behavior, not a bug.

Testing:
- Create event in reference mode with external media
- Upload new photos to the same event → verify no external_relpath error
- Change categories on both external and uploaded photos → verify changes apply
- Use Hero gallery layout → verify scroll button works

Fixes #30
2025-11-04 20:05:44 +00:00
Claude 41628b0578 Remove documentation file 2025-11-04 19:56:08 +00:00
Claude 8826fb7a12 Fix issue #22: Gallery filter counts disappearing and upload errors
This commit comprehensively addresses the persistent issues reported in #22:

1. Gallery Filter Bug - Counts Disappearing
   - Root cause: Frontend fetched filtered photos from backend, then
     calculated counts from already-filtered data
   - Fix: Always fetch ALL photos, apply filtering client-side only
   - Benefits: Counts always accurate, filters work correctly in combo
   - Changed: frontend/src/components/gallery/GalleryView.tsx:76

2. Upload ENOENT Errors
   - Root cause: /tmp/uploads/ directory assumed to exist
   - Fix: Verify and create temp directory before multer initialization
   - Changed: backend/src/routes/gallery.js:814-825

3. Upload "Not Iterable" Errors
   - Root cause: normalizeFiles() didn't handle null/edge cases
   - Fix: Enhanced error handling with try-catch and graceful degradation
   - Changed: backend/src/services/photoProcessor.js:10-52

4. Enhanced Upload Debugging
   - Added file existence verification before copy operations
   - Improved temp file cleanup (properly handle ENOENT)
   - Comprehensive error logging with full context
   - Changed: backend/src/services/photoProcessor.js:108-233

Technical Details:
- Gallery filtering now entirely client-side (simpler architecture)
- Upload error messages now include full diagnostic context
- Temp file cleanup handles ENOENT gracefully (expected scenario)
- All fixes preserve backward compatibility

Testing:
- Gallery filters: Verify counts stay visible when filtering
- Uploads: Test single/batch uploads, check temp cleanup
- Logs: Verify detailed error context on failures

See ISSUE_22_FIX_SUMMARY.md for complete analysis and testing guide.

Fixes #22
2025-11-04 19:52:11 +00:00
Gitea Actions Bot f29e9db99d chore: bump version to 1.1.15 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-29 11:30:46 +00:00
paul 81416737e8 chore: remove sensitive files for GitHub mirror 2025-10-29 11:29:50 +00:00
paul d2e97567a9 Merge pull request 'Fix mobile overlay and deps per #43' (#3) from fix/gallery-mobile into main
Test and Lint / backend-test (push) Successful in 1m24s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Reviewed-on: #3
2025-10-29 12:25:38 +01:00
paul 69538b86ea Fix mobile overlay and deps per #43
Test and Lint / backend-test (pull_request) Successful in 1m24s
Test and Lint / frontend-test (pull_request) Successful in 1m59s
continuous-integration/drone/pr Build is passing
2025-10-29 12:19:43 +01:00
paul f6f1c31369 Fix mobile overlay and deps per #43
continuous-integration/drone/pr Build is failing
Test and Lint / backend-test (pull_request) Successful in 2m10s
Test and Lint / frontend-test (pull_request) Successful in 2m0s
2025-10-29 11:11:53 +01:00
Gitea Actions Bot b76e45cb54 chore: bump version to 1.1.14 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-20 12:41:34 +00:00
Paul Nothaft 5b5e431b08 Implement per-IP gallery lockouts and UI controls (#42)
Test and Lint / backend-test (push) Successful in 1m54s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 1m50s
2025-10-20 14:35:23 +02:00
Gitea Actions Bot 07759a0e40 chore: bump version to 1.1.13 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-15 05:29:19 +00:00
Paul Nothaft 31fd64c83c Add short gallery URL toggle and token support (#38)
Test and Lint / backend-test (push) Successful in 1m55s
Test and Lint / frontend-test (push) Successful in 1m54s
continuous-integration/drone/push Build is passing
2025-10-15 07:21:09 +02:00
Paul Nothaft 775c5159ea Add customer contact fields and admin API docs (refs #41) 2025-10-14 18:29:21 +02:00
Paul Nothaft 8f297e25c4 Make photo upload limit configurable via admin settings (#40) 2025-10-14 16:27:44 +02:00
Paul Nothaft ccb65b892b Rename setup script and bump installer version (#39) 2025-10-14 15:48:55 +02:00
Paul Nothaft 52f8f1f738 Upgrade nodemailer to 7.0.7 (GHSA-mm7p-fcc7-pg87)
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 21:11:38 +02:00
Paul Nothaft e731e7b47c Address tar-fs CVE-2025-59343
Test and Lint / backend-test (push) Successful in 1m21s
Test and Lint / frontend-test (push) Has been cancelled
2025-10-13 21:09:52 +02:00
Paul Nothaft 2bccb1a439 Handle pre-existing docker app dir (#32)
Test and Lint / backend-test (push) Successful in 1m27s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 20:59:50 +02:00
Paul Nothaft df10fc677e Send gallery image requests with bearer token fallback (#31)
Test and Lint / backend-test (push) Successful in 1m26s
Test and Lint / frontend-test (push) Successful in 1m53s
2025-10-13 20:29:07 +02:00
Gitea Actions Bot 8c690155bf chore: bump frontend version to 1.1.12 2025-10-13 18:19:57 +00:00
Paul Nothaft 1b1e4f715d Rename event owner fields to customer (#37)
Test and Lint / backend-test (push) Successful in 1m28s
Test and Lint / frontend-test (push) Successful in 1m55s
2025-10-13 20:09:28 +02:00
Paul Nothaft 68eb9ba552 Clarify event owner labeling in UI (#37)
Test and Lint / backend-test (push) Successful in 1m26s
Test and Lint / frontend-test (push) Successful in 1m52s
2025-10-13 20:02:52 +02:00
Paul Nothaft 7040865154 Fix admin password reset guidance in setup.sh (#34)
Test and Lint / backend-test (push) Successful in 1m27s
Test and Lint / frontend-test (push) Successful in 1m56s
2025-10-13 19:58:07 +02:00
Paul Nothaft 013be18d98 fix: clear notifications via API (#35)
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 1m55s
2025-10-13 17:41:06 +02:00
Paul Nothaft 3c2a79a31a feat: allow admin email updates in UI (#36) 2025-10-13 17:21:03 +02:00
Gitea Actions Bot f20472ca26 chore: bump version to 1.1.11 (backend + frontend) 2025-10-12 19:23:19 +00:00
paul a1e9fb6ffc Fix setup clone path conflict for issue #32 2025-10-12 21:20:36 +02:00
Gitea Actions Bot 87f4526220 chore: bump version to 1.1.10 (backend + frontend) 2025-10-12 19:18:37 +00:00
paul 665ce5a6e7 Fix issues #31 #33 #34 #35 #36 2025-10-12 21:03:07 +02:00
Gitea Actions Bot d42a11680f chore: bump version to 1.1.9 (backend + frontend) 2025-10-06 13:18:43 +00:00
paul 8c41dd626d Fix hero layout tile sizing and scroll hook 2025-10-06 15:15:34 +02:00
Gitea Actions Bot 38dd74b893 chore: bump version to 1.1.8 (backend + frontend) 2025-10-03 05:19:52 +00:00
paul 775e417e55 Fix admin reference mode regressions 2025-10-02 23:39:17 +02:00
paul fc1bf53412 fix: harden gallery downloads and per-gallery auth
Test and Lint / backend-test (push) Successful in 1m57s
Test and Lint / frontend-test (push) Successful in 1m57s
2025-10-01 16:00:37 +02:00
paul 5d6c061f1c feat: support per-gallery password toggle 2025-10-01 16:00:37 +02:00
Gitea Actions Bot 45e835a51a chore: bump frontend version to 1.1.7 2025-09-27 06:14:25 +00:00
Gitea Actions Bot afc00090cf chore: bump frontend version to 1.1.6 2025-09-27 06:11:45 +00:00
paul 59750dea15 Enforce mandatory gallery passwords in UI
Test and Lint / backend-test (push) Successful in 1m23s
Test and Lint / frontend-test (push) Successful in 1m51s
2025-09-27 08:05:59 +02:00
Gitea Actions Bot 2fe32e9a69 chore: bump backend version to 1.1.5 2025-09-27 05:59:32 +00:00
paul 5f8c8c5508 Fix branding asset storage path
Test and Lint / backend-test (push) Successful in 1m47s
Test and Lint / frontend-test (push) Successful in 1m56s
2025-09-26 17:26:39 +02:00
Gitea Actions Bot fb739f221d chore: bump version to 1.1.4 (backend + frontend) 2025-09-24 15:39:34 +00:00
paul b5399aaa9b Add installer flag to regenerate admin credentials
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 1m54s
2025-09-24 17:33:54 +02:00
Gitea Actions Bot a4595e2ab2 chore: bump backend version to 1.1.3 2025-09-22 20:50:54 +00:00
paul 0911711a37 Deduplicate external media imports by filename
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m5s
2025-09-22 22:44:52 +02:00
paul f2c7594b23 Refetch gallery data after lightbox feedback (#29)
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m31s
2025-09-22 22:32:56 +02:00
paul 32355fabad Revert "Ignore local Playwright tests directories"
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m9s
This reverts commit c127fd829d.
2025-09-22 21:31:28 +02:00
paul c127fd829d Ignore local Playwright tests directories
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m8s
2025-09-22 21:30:41 +02:00
Gitea Actions Bot cab5b0d795 chore: bump backend version to 1.1.2 2025-09-22 17:08:29 +00:00
paul ba95aad3c6 Switch backend image to Node 20 to address cross-spawn CVE
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m6s
2025-09-22 19:03:33 +02:00
paul c1be7d6785 Harden photo resolver path handling
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Has been cancelled
2025-09-22 18:59:49 +02:00
paul 0024686dc2 Align simple setup storage paths (#27)
Test and Lint / backend-test (push) Successful in 1m46s
Test and Lint / frontend-test (push) Successful in 2m12s
2025-09-22 18:54:13 +02:00
paul 96b8b77792 Fix release workflow when tag already exists
Test and Lint / backend-test (push) Successful in 1m35s
Test and Lint / frontend-test (push) Successful in 2m10s
2025-09-22 14:46:45 +02:00
Gitea Actions Bot 9d2726b3d3 chore: bump frontend version to 1.1.1 2025-09-22 12:41:16 +00:00
paul 8d6ddd257d Fix gallery login persistence and favorites (#29)
Test and Lint / backend-test (push) Successful in 2m6s
Test and Lint / frontend-test (push) Successful in 2m16s
2025-09-22 14:33:11 +02:00
Gitea Actions Bot e0865b81b6 chore: bump backend version to 1.1.1 2025-09-21 20:47:47 +00:00
paul d4404e39bd fix: prefer admin token on admin routes (#23 #28)
Test and Lint / backend-test (push) Successful in 2m9s
Test and Lint / frontend-test (push) Successful in 2m31s
2025-09-21 22:37:30 +02:00
paul 8611206396 Fix PicPeak regressions and close #22 #24 #25 #26 #27 #28 2025-09-21 22:03:07 +02:00
paul 39d2244e1e chore: switch versioning workflows to manual triggers 2025-09-19 22:31:55 +02:00
Gitea Actions Bot eb626be22c chore: bump version to 1.0.130 (backend + frontend) 2025-09-19 14:47:37 +00:00
paul aaaf59817b fix: stabilize uploads and guest feedback filters
Mirror to GitHub / mirror (push) Successful in 1m54s
Test and Lint / backend-test (push) Successful in 1m51s
Test and Lint / frontend-test (push) Successful in 2m11s
Version and Release / version-bump (push) Successful in 1m49s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-19 16:39:18 +02:00
paul 2a4d38813f feat: overhaul public landing page and backup tooling 2025-09-19 16:39:18 +02:00
Gitea Actions Bot ad9c6d63d3 chore: bump backend version to 1.0.129 2025-09-18 14:54:28 +00:00
paul 8c77b30de6 Default auth cookies to non-secure for HTTP installs
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m36s
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-18 16:48:14 +02:00
Gitea Actions Bot e51347d0a1 chore: bump version to 1.0.128 (backend + frontend) 2025-09-18 14:05:43 +00:00
paul 71e7179145 Harden auth cookies and fix native schema for event creation
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-18 15:59:26 +02:00
Gitea Actions Bot bda76ff513 chore: bump backend version to 1.0.125 2025-09-18 10:49:54 +00:00
paul 097ce2c205 Fix native install schema gaps (closes #20)
Mirror to GitHub / mirror (push) Successful in 1m38s
Test and Lint / backend-test (push) Successful in 1m44s
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Failing after 1m20s
Version and Release / trigger-drone (push) Has been skipped
2025-09-18 12:42:05 +02:00
Gitea Actions Bot 1d8be3d840 chore: bump frontend version to 1.0.127 2025-09-17 21:40:18 +00:00
paul aebb8e66cb Make lightbox feedback panel sticky on desktop
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m35s
Test and Lint / frontend-test (push) Successful in 2m11s
Version and Release / version-bump (push) Successful in 1m17s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-17 23:33:43 +02:00
Gitea Actions Bot ed2a278da2 chore: bump frontend version to 1.0.126 2025-09-17 21:22:44 +00:00
paul db2f5da66a Ensure gallery comment filter hides moderated comments
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m38s
Test and Lint / frontend-test (push) Successful in 2m25s
Version and Release / version-bump (push) Successful in 1m46s
Version and Release / trigger-drone (push) Successful in 4s
2025-09-17 23:15:28 +02:00
paul 19f8facc49 Refine gallery feedback actions
Mirror to GitHub / mirror (push) Successful in 2m10s
Test and Lint / backend-test (push) Successful in 1m48s
Test and Lint / frontend-test (push) Successful in 2m14s
Version and Release / version-bump (push) Failing after 50s
Version and Release / trigger-drone (push) Has been skipped
2025-09-17 22:27:13 +02:00
paul b03760ab01 feat(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 2025-09-16 10:11:08 +02:00
paul 526dcd8dfc fix(gallery/filters): always apply global liked/favorited filters by aggregate counts (ignore guest_id); resolves mismatch between client guest_id and server identifier 2025-09-16 09:57:35 +02:00
paul 5b2561b6f1 fix(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. 2025-09-16 09:28:12 +02:00
paul 3a6d06192a fix(gallery): feedback filter headline + horizontal icons in sidebar (compact variant); ensure sidebar content scrolls (flex-col container) 2025-09-16 09:15:47 +02:00
paul 4b64b80b20 ui(gallery): feedback filter headline + horizontal compact icons (desktop+mobile); render only when feedback enabled 2025-09-16 09:11:32 +02:00
paul ff89f96e31 fix(gallery/sidebar): compact icon-only feedback filter in sidebar (vertical, small) to avoid overflow; use GalleryFilter variant=compact 2025-09-16 09:03:24 +02:00
paul 465f997752 feat(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
Mirror to GitHub / mirror (push) Successful in 1m57s
Test and Lint / backend-test (push) Successful in 1m49s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Failing after 55s
Version and Release / trigger-drone (push) Has been skipped
2025-09-15 22:59:21 +02:00
paul 6948aaa92a feat(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 2025-09-15 22:59:21 +02:00
paul 4c7b49a5f6 fix(admin/feedback): use correct event id when rendering photo thumbnails
- Replace undefined eventId with route param id to build admin thumbnail URL
- Fixes runtime ReferenceError on /admin/events/:id/feedback when opening Feedback tab

Refs: #19
2025-09-15 22:59:21 +02:00
paul 6368f1027f feat(gallery): add quick Like/Favorite actions on thumbnails across layouts
- Grid, Masonry, Mosaic, Timeline, Hero, and Carousel layouts now expose inline Like/Favorite buttons when feedback is enabled
- Respect requireNameEmail; prompt via identity modal before submitting feedback
- Wire feedback settings from GalleryView -> layouts via feedbackOptions

feat(lightbox): keep feedback usable while navigating

- Add initialShowFeedback prop; preserve panel state across navigation
- Offset Next button when feedback panel is open so it remains accessible
- Hide/avoid overlapping nav on small screens

Refs: #19
2025-09-15 22:59:21 +02:00
paul d64e7d08de feat(admin): refine header layout and logo placement
- Left-align logo across breakpoints; remove duplicate centered/mobile blocks
- Add date separator and spacing; keep header compact and readable

fix(admin): prevent category badge overlap in grid

- Move badge to top-left; make non-interactive; constrain width to avoid checkbox collisions

chore(docker): support ADMIN_PASSWORD in docker-compose

- Allow setting initial admin password via env for easier provisioning

chore(backend): normalize EOF newline in set-admin-password.js

Refs: admin-header-layout, category-badge-overlap, docker-admin-password
2025-09-15 22:59:21 +02:00
Gitea Actions Bot eb3751cb52 chore: bump frontend version to 1.0.125 2025-09-14 15:08:14 +00:00
paul 9fda54bd06 feat(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
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-14 17:02:45 +02:00
Gitea Actions Bot 0d77a3a0a8 chore: bump version to 1.0.124 (backend + frontend) 2025-09-14 14:25:37 +00:00
paul 0618b78725 feat(setup/docker): auto-set PUID/PGID from invoking user and chown bind-mount folders; create missing data/events dirs
Mirror to GitHub / mirror (push) Successful in 35s
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 1m3s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-14 16:20:08 +02:00
paul 0178e71c67 docs: add PUID/PGID note for Docker bind mounts to avoid permission issues
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m16s
2025-09-14 16:10:46 +02:00
Gitea Actions Bot aa9b3a0227 chore: bump version to 1.0.123 (backend + frontend) 2025-09-14 13:53:37 +00:00
paul 410a33fecf feat(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
Mirror to GitHub / mirror (push) Successful in 1m41s
Test and Lint / backend-test (push) Successful in 1m47s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 1m5s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-14 15:46:35 +02:00
Gitea Actions Bot 05ebaaeedb chore: bump version to 1.0.122 (backend + frontend) 2025-09-09 18:54:09 +00:00
paul 84d0f63d36 feat(setup): remove --admin-password; print admin credentials from ADMIN_CREDENTIALS.txt; fix ADMIN_URL to avoid /admin/admin; update native service commands
Mirror to GitHub / mirror (push) Successful in 37s
Test and Lint / backend-test (push) Successful in 1m29s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 58s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:48:49 +02:00
Gitea Actions Bot 6a4b549d9f chore: bump version to 1.0.121 (backend + frontend) 2025-09-09 18:45:28 +00:00
paul f3604b438b fix(native): remove obsolete workers service; restart only backend; add API request logging and preflight handler; keep static assets outside CORS
Mirror to GitHub / mirror (push) Successful in 33s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 56s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:40:17 +02:00
Gitea Actions Bot 531831e84b chore: bump backend version to 1.0.120 2025-09-09 18:28:39 +00:00
paul 90bb21e38b fix(cors): scope CORS to /api only and avoid throwing on disallowed origins; prevents static asset 500s on native
Mirror to GitHub / mirror (push) Successful in 37s
Test and Lint / backend-test (push) Successful in 1m28s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 52s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:23:25 +02:00
paul 2f1a137342 ci: make ghcr login non-fatal and gate pushes/scans on login success; build images regardless (supports transient GHCR outages)
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 44s
Version and Release / trigger-drone (push) Has been skipped
2025-09-09 20:12:15 +02:00
paul adf576fbe1 fix(setup/update): detect native installs first (/opt/picpeak/app/backend or systemd unit); avoid false docker updates on root
Mirror to GitHub / mirror (push) Successful in 35s
Test and Lint / backend-test (push) Successful in 1m29s
Test and Lint / frontend-test (push) Has been cancelled
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
2025-09-09 20:08:51 +02:00
Gitea Actions Bot 4264026bbe chore: bump backend version to 1.0.119 2025-09-09 18:06:15 +00:00
paul 24b4a314a9 fix(native/http): disable CSP upgrade-insecure-requests and HSTS unless ENABLE_HSTS=true; prevents HTTPS upgrades on HTTP installs
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 1m3s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 20:00:48 +02:00
Gitea Actions Bot ba825823a0 chore: bump backend version to 1.0.118 2025-09-09 17:58:50 +00:00
paul fb16b7bbb8 feat(native): auto-serve SPA when dist exists (unless SERVE_FRONTEND=false); add clear logging; serve index.html for /admin
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m34s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 59s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 19:52:46 +02:00
Gitea Actions Bot 8404125ff0 chore: bump version to 1.0.117 (backend + frontend) 2025-09-09 17:10:38 +00:00
paul 61ad2d61c1 feat(native): serve built frontend from backend; build frontend during install/update; ensure env flags (SERVE_FRONTEND, FRONTEND_DIR)
Mirror to GitHub / mirror (push) Successful in 43s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 58s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 19:04:39 +02:00
Gitea Actions Bot 9fd6b44487 chore: bump version to 1.0.116 (backend + frontend) 2025-09-09 15:47:41 +00:00
paul 9fe10bcce2 feat(native): build frontend and serve SPA from backend (SERVE_FRONTEND); fix Cannot GET /admin on native installs
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m33s
Test and Lint / frontend-test (push) Successful in 2m7s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 17:41:41 +02:00
Gitea Actions Bot f2abb40987 chore: bump version to 1.0.115 (backend + frontend) 2025-09-09 15:32:47 +00:00
paul 3697344cd0 fix(setup/native): handle forced updates safely by fetch+checkout/reset instead of pull; stable on rewritten histories
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m29s
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 1m0s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 17:27:27 +02:00
Gitea Actions Bot 4aa0ff705f chore: bump version to 1.0.114 (backend + frontend) 2025-09-09 15:25:05 +00:00
paul dc482e614a fix(setup/native): Debian 12 compatibility (reliable RAM detection, sudo-less run_as_user, git safe.directory); ensure SQLite data dir; use user for migrate
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m37s
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 17:19:03 +02:00
Gitea Actions Bot 448882cfef chore: bump version to 1.0.113 (backend + frontend) 2025-09-09 13:31:10 +00:00
paul 7f9cb33a40 chore(native): ensure SQLite data dir exists in setup and at runtime; keep native paths consistent under /opt/picpeak/app
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m38s
Test and Lint / frontend-test (push) Successful in 2m3s
Version and Release / version-bump (push) Successful in 59s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 15:25:04 +02:00
Gitea Actions Bot 798f6211e0 chore: bump version to 1.0.112 (backend + frontend) 2025-09-09 09:46:52 +00:00
paul b992b151d3 fix(native): correct setup paths to /opt/picpeak/app, update repo URL, add sqlite prod support; docs path fixes
Mirror to GitHub / mirror (push) Successful in 1m27s
Test and Lint / backend-test (push) Successful in 1m49s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 1m8s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-09 11:25:41 +02:00
paul 87b8414e44 fix(setup/native): correct repo URL, paths, and systemd for native install; support sqlite in production knex config 2025-09-09 11:13:01 +02:00
paul ee13556c5c docs(readme): reflect new External Media reference mode and update roadmap (gallery feedback status)
Mirror to GitHub / mirror (push) Successful in 36s
Test and Lint / backend-test (push) Successful in 1m31s
Test and Lint / frontend-test (push) Successful in 2m14s
2025-09-06 10:16:08 +02:00
Gitea Actions Bot afeb35a446 chore: bump version to 1.0.111 (backend + frontend) 2025-09-06 07:22:38 +00:00
paul ab324f1928 fix(frontend): add missing externalMedia service and mount admin external-media routes; verify Vite build
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 1m37s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-06 09:15:49 +02:00
Gitea Actions Bot 78ab0ad2e9 chore: bump version to 1.0.110 (backend + frontend) 2025-09-05 22:06:43 +00:00
paul 49c77785e7 feat(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 – gallery feature request: https://github.com/the-luap/picpeak/issues/17
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Failing after 1m50s
Version and Release / version-bump (push) Successful in 1m1s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-05 23:44:30 +02:00
Gitea Actions Bot 1d826accdc chore: bump version to 1.0.109 (backend + frontend) 2025-09-05 13:07:58 +00:00
paul ceefe4f5a7 chore: normalize .gitignore after cleanup
Mirror to GitHub / mirror (push) Successful in 49s
Test and Lint / backend-test (push) Successful in 1m52s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 1m4s
Version and Release / trigger-drone (push) Successful in 4s
2025-09-05 15:01:16 +02:00
paul e9171c7115 docs: follow-up on PR #15 — clarify VITE_API_URL usage, compose mounts, and admin routing (refs #15) 2025-09-05 15:01:16 +02:00
paul 758c085467 docs: clarify VITE_API_URL usage; remove FRONTEND_API_URL; add storage vars; simplify compose mounts and external DB example (refs #18) 2025-09-05 15:01:16 +02:00
paul ecbc48815d docs(compose): fix backend healthcheck path; remove frontend VITE_API_URL env and document /api proxy (refs #18) 2025-09-05 15:01:16 +02:00
paul e91b138154 chore: remove unintended local artifacts and SQLite DB; update .gitignore (refs #18) 2025-09-05 15:01:16 +02:00
paul dad1787aad docs: fix deployment/admin routing and CORS guidance; add AGENTS.md; ignore AGENTS.md (refs #18) 2025-09-05 15:01:16 +02:00
paul 909e760447 feat: implement gallery logo customization (Issue #17)
Added comprehensive logo customization features for gallery views:
- Logo size options (small, medium, large, xlarge, custom)
- Logo position control (left, center, right)
- Display mode settings (logo only, text only, logo and text)
- Visibility controls for header and hero sections
- Custom height configuration for fine-tuning

Changes:
- Added database migration for 6 new logo customization settings
- Extended backend APIs to handle logo customization fields
- Updated GalleryLayout.tsx with dynamic logo rendering logic
- Added logo upload functionality to BrandingPage.tsx
- Extended settings service with logo customization types

This addresses the issue where the gallery logo was "very large and centered"
by providing full control over logo appearance and positioning.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 14:56:09 +02:00
paul 41857ec499 feat: implement feedback filter for liked/favorited photos (Issue #17)
Implemented Feature Request 1 from github.com/the-luap/picpeak/issues/17:
- Added filter functionality to display only liked or favorited photos
- Integrated feedback filter directly into PhotoFilterBar component
- Implemented responsive design with proper mobile/tablet/desktop layouts
- Filter only shows when feedback is enabled for the gallery
- Added proper count display for liked and favorited photos

Improvements:
- Fixed responsive breakpoints (mobile <768px, tablet 768-1023px, desktop ≥1024px)
- Feedback filter shows inline with categories on desktop with vertical divider
- On mobile/tablet, filter appears below categories to prevent layout issues
- Added horizontal scrolling for category buttons to prevent cut-off

Code cleanup:
- Removed all debug console.log statements from production code
- Removed test route from backend gallery.js
- Cleaned up unnecessary logging in frontend components

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-05 14:56:09 +02:00
Gitea Actions Bot f7a8765f58 chore: bump version to 1.0.108 (backend + frontend) 2025-09-02 15:46:57 +00:00
paul 214f120f7a chore: update system metrics
Mirror to GitHub / mirror (push) Successful in 45s
Test and Lint / backend-test (push) Successful in 1m43s
Test and Lint / frontend-test (push) Successful in 2m14s
Version and Release / version-bump (push) Successful in 1m5s
Version and Release / trigger-drone (push) Successful in 3s
2025-09-02 17:40:23 +02:00
paul f26becad1d fix: resolve feedback validation issues from GitHub issue #16
- Fixed 400 Bad Request error when submitting feedback with name/email required
- Updated backend validation to properly handle empty/undefined name/email fields
- Modified frontend components to send undefined instead of empty strings when fields are not provided
- Fixed thumbnail display issue in moderation view by using correct admin API endpoints
- Updated FeedbackModerationPanel and EventFeedbackPage to display thumbnails correctly

The issue was caused by the validation logic treating empty strings differently than undefined values.
Frontend components now properly send undefined when name/email are not provided, and the backend
validation correctly handles both cases.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 17:40:23 +02:00
paul 67ff415840 fix: resolve feedback validation issues from GitHub issue #16
- Fixed backend validation to properly handle empty strings in validateGuestRequirements
- Added Boolean conversion for SQLite boolean values in feedback settings API response
- Created FeedbackIdentityModal component for collecting name/email when required
- Updated PhotoLikes, PhotoRating, and PhotoFavorites components to show modal when requireNameEmail is true
- Fixed issue where require_name_email field was not reaching frontend due to missing boolean conversion

This ensures that when 'Require Name & Email' is enabled, guests are prompted with a modal to provide their information before submitting feedback, preventing 400 Bad Request errors.

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 17:40:23 +02:00
Gitea Actions Bot 88659f1fa6 chore: bump frontend version to 1.0.107 2025-09-02 14:14:52 +00:00
paul c1e10f14a3 fix: resolve translation interpolation issue for download button
Mirror to GitHub / mirror (push) Successful in 42s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Successful in 1m13s
Version and Release / trigger-drone (push) Successful in 3s
Fixed the download selected button not displaying count properly.
The translation key 'gallery.downloadSelected' was not receiving
the count parameter for interpolation, causing "{{count}}" to
display literally instead of the actual number.

Fixes the issue where the button showed:
- "Download {{count}} Selected" instead of "Download 2 Selected"
- "{{count}} ausgewählte herunterladen" instead of "3 ausgewählte herunterladen"

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-02 16:08:14 +02:00
Gitea Actions Bot 0881a0fa71 chore: bump version to 1.0.106 (backend + frontend) 2025-09-01 21:03:36 +00:00
paul e91209f7cb fix: resolve multiple issues from GitHub issue #14
Mirror to GitHub / mirror (push) Successful in 46s
Test and Lint / backend-test (push) Successful in 1m54s
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 1m5s
Version and Release / trigger-drone (push) Successful in 3s
- Fixed duplicate German translation for 'downloadSelected' button
- Added client_max_body_size configuration in nginx for file uploads
- Fixed date parsing in FeedbackModerationPanel to handle timestamps
- Fixed admin authentication context (req.admin vs req.user) in feedback routes
- Enhanced clipboard functionality with fallback for non-HTTPS contexts
- Fixed authentication token handling for numeric event IDs in uploads

These changes ensure comment moderation works properly, file uploads are configured correctly, and the UI handles all edge cases properly.

Fixes #14

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-01 22:56:44 +02:00
paul 828d6bc456 fix: correct script name in Gitea mirror workflow
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Successful in 2m17s
Version and Release / version-bump (push) Successful in 1m1s
Version and Release / trigger-drone (push) Has been skipped
- Fix script name from gitea-runner.sh to install-gitea-runner.sh
- Update system metrics

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:37:12 +02:00
paul f945573f09 chore: update system metrics
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m39s
Test and Lint / frontend-test (push) Has started running
Version and Release / version-bump (push) Has been cancelled
Version and Release / trigger-drone (push) Has been cancelled
2025-08-29 22:33:38 +02:00
paul 296430e4d7 fix: update Gitea mirror workflow to selectively remove scripts
- Only remove gitea-runner.sh instead of entire scripts directory
- Preserve useful deployment and utility scripts in GitHub mirror
- Update system metrics

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:33:38 +02:00
Gitea Actions Bot 7b517fa290 chore: bump version to 1.0.105 (backend + frontend) 2025-08-29 20:28:39 +00:00
paul 2c9a56f217 docs: update deployment guide with GitHub Container Registry images
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m42s
Test and Lint / frontend-test (push) Successful in 2m20s
Version and Release / version-bump (push) Successful in 1m9s
Version and Release / trigger-drone (push) Successful in 3s
- Added instructions for using pre-built images from ghcr.io
- Created docker-compose.production.yml for quick deployment with official images
- Updated deployment guide with two methods:
  1. Using pre-built images (fastest, recommended)
  2. Building from source (for customization)
- Updated SIMPLE_SETUP references to use new unified script
- Added specific version deployment instructions
- Maintained backward compatibility with local build process

The pre-built images eliminate build time and ensure consistent deployments
across environments. Users can now deploy PicPeak in minutes using:
- ghcr.io/the-luap/picpeak/backend:latest
- ghcr.io/the-luap/picpeak/frontend:latest

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:22:40 +02:00
paul 986b101040 fix: remove unnecessary publish-manifest job from Docker workflow
Mirror to GitHub / mirror (push) Successful in 44s
Test and Lint / backend-test (push) Successful in 1m40s
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Successful in 58s
Version and Release / trigger-drone (push) Has been skipped
The publish-manifest job was failing because it tried to create manifests
from non-existent architecture-specific tags (latest-amd64, latest-arm64).

docker/build-push-action@v5 already creates multi-arch manifests automatically
when building for multiple platforms, making this job redundant.

The workflow now correctly builds and pushes multi-arch images in a single
step with proper manifest lists included.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-29 22:16:35 +02:00
258 changed files with 23195 additions and 7846 deletions
-494
View File
@@ -1,494 +0,0 @@
[
{
"timestamp": 1756496746582,
"memoryTotal": 25769803776,
"memoryUsed": 25277186048,
"memoryFree": 492617728,
"memoryUsagePercent": 98.08839162190756,
"memoryEfficiency": 1.9116083780924384,
"cpuCount": 14,
"cpuLoad": 0.24197823660714285,
"platform": "darwin",
"uptime": 5182900
},
{
"timestamp": 1756496776582,
"memoryTotal": 25769803776,
"memoryUsed": 25447661568,
"memoryFree": 322142208,
"memoryUsagePercent": 98.74992370605469,
"memoryEfficiency": 1.2500762939453125,
"cpuCount": 14,
"cpuLoad": 0.1962890625,
"platform": "darwin",
"uptime": 5182930
},
{
"timestamp": 1756496806600,
"memoryTotal": 25769803776,
"memoryUsed": 25413042176,
"memoryFree": 356761600,
"memoryUsagePercent": 98.61558278401694,
"memoryEfficiency": 1.3844172159830634,
"cpuCount": 14,
"cpuLoad": 0.22129603794642858,
"platform": "darwin",
"uptime": 5182960
},
{
"timestamp": 1756496836604,
"memoryTotal": 25769803776,
"memoryUsed": 25516834816,
"memoryFree": 252968960,
"memoryUsagePercent": 99.01835123697916,
"memoryEfficiency": 0.9816487630208428,
"cpuCount": 14,
"cpuLoad": 0.21344866071428573,
"platform": "darwin",
"uptime": 5182990
},
{
"timestamp": 1756496866605,
"memoryTotal": 25769803776,
"memoryUsed": 25414123520,
"memoryFree": 355680256,
"memoryUsagePercent": 98.61977895100912,
"memoryEfficiency": 1.380221048990876,
"cpuCount": 14,
"cpuLoad": 0.21822684151785715,
"platform": "darwin",
"uptime": 5183020
},
{
"timestamp": 1756496896607,
"memoryTotal": 25769803776,
"memoryUsed": 25196363776,
"memoryFree": 573440000,
"memoryUsagePercent": 97.77475992838541,
"memoryEfficiency": 2.225240071614593,
"cpuCount": 14,
"cpuLoad": 0.20950753348214285,
"platform": "darwin",
"uptime": 5183050
},
{
"timestamp": 1756496926609,
"memoryTotal": 25769803776,
"memoryUsed": 25503711232,
"memoryFree": 266092544,
"memoryUsagePercent": 98.96742502848306,
"memoryEfficiency": 1.0325749715169366,
"cpuCount": 14,
"cpuLoad": 0.23154994419642858,
"platform": "darwin",
"uptime": 5183080
},
{
"timestamp": 1756496956610,
"memoryTotal": 25769803776,
"memoryUsed": 25550979072,
"memoryFree": 218824704,
"memoryUsagePercent": 99.15084838867188,
"memoryEfficiency": 0.849151611328125,
"cpuCount": 14,
"cpuLoad": 0.20741489955357142,
"platform": "darwin",
"uptime": 5183110
},
{
"timestamp": 1756496986609,
"memoryTotal": 25769803776,
"memoryUsed": 25552404480,
"memoryFree": 217399296,
"memoryUsagePercent": 99.15637969970703,
"memoryEfficiency": 0.8436203002929688,
"cpuCount": 14,
"cpuLoad": 0.22112165178571427,
"platform": "darwin",
"uptime": 5183140
},
{
"timestamp": 1756497016611,
"memoryTotal": 25769803776,
"memoryUsed": 25680560128,
"memoryFree": 89243648,
"memoryUsagePercent": 99.653689066569,
"memoryEfficiency": 0.34631093343099906,
"cpuCount": 14,
"cpuLoad": 0.23615373883928573,
"platform": "darwin",
"uptime": 5183170
},
{
"timestamp": 1756497046610,
"memoryTotal": 25769803776,
"memoryUsed": 25631408128,
"memoryFree": 138395648,
"memoryUsagePercent": 99.46295420328775,
"memoryEfficiency": 0.5370457967122491,
"cpuCount": 14,
"cpuLoad": 0.19859095982142858,
"platform": "darwin",
"uptime": 5183200
},
{
"timestamp": 1756497076611,
"memoryTotal": 25769803776,
"memoryUsed": 25655099392,
"memoryFree": 114704384,
"memoryUsagePercent": 99.55488840738931,
"memoryEfficiency": 0.44511159261068656,
"cpuCount": 14,
"cpuLoad": 0.23831612723214285,
"platform": "darwin",
"uptime": 5183230
},
{
"timestamp": 1756497106613,
"memoryTotal": 25769803776,
"memoryUsed": 25590169600,
"memoryFree": 179634176,
"memoryUsagePercent": 99.30292765299478,
"memoryEfficiency": 0.6970723470052178,
"cpuCount": 14,
"cpuLoad": 0.29031808035714285,
"platform": "darwin",
"uptime": 5183260
},
{
"timestamp": 1756497136613,
"memoryTotal": 25769803776,
"memoryUsed": 25682378752,
"memoryFree": 87425024,
"memoryUsagePercent": 99.66074625651041,
"memoryEfficiency": 0.3392537434895928,
"cpuCount": 14,
"cpuLoad": 0.26834542410714285,
"platform": "darwin",
"uptime": 5183290
},
{
"timestamp": 1756497166614,
"memoryTotal": 25769803776,
"memoryUsed": 25591283712,
"memoryFree": 178520064,
"memoryUsagePercent": 99.3072509765625,
"memoryEfficiency": 0.6927490234375,
"cpuCount": 14,
"cpuLoad": 0.2820521763392857,
"platform": "darwin",
"uptime": 5183320
},
{
"timestamp": 1756497196616,
"memoryTotal": 25769803776,
"memoryUsed": 25577848832,
"memoryFree": 191954944,
"memoryUsagePercent": 99.25511678059897,
"memoryEfficiency": 0.7448832194010322,
"cpuCount": 14,
"cpuLoad": 0.23618861607142858,
"platform": "darwin",
"uptime": 5183350
},
{
"timestamp": 1756497226617,
"memoryTotal": 25769803776,
"memoryUsed": 25343754240,
"memoryFree": 426049536,
"memoryUsagePercent": 98.34671020507812,
"memoryEfficiency": 1.653289794921875,
"cpuCount": 14,
"cpuLoad": 0.23228236607142858,
"platform": "darwin",
"uptime": 5183380
},
{
"timestamp": 1756497256617,
"memoryTotal": 25769803776,
"memoryUsed": 25662668800,
"memoryFree": 107134976,
"memoryUsagePercent": 99.58426157633463,
"memoryEfficiency": 0.41573842366537406,
"cpuCount": 14,
"cpuLoad": 0.19471958705357142,
"platform": "darwin",
"uptime": 5183410
},
{
"timestamp": 1756497286619,
"memoryTotal": 25769803776,
"memoryUsed": 25610125312,
"memoryFree": 159678464,
"memoryUsagePercent": 99.38036600748697,
"memoryEfficiency": 0.6196339925130303,
"cpuCount": 14,
"cpuLoad": 0.19126674107142858,
"platform": "darwin",
"uptime": 5183440
},
{
"timestamp": 1756497316620,
"memoryTotal": 25769803776,
"memoryUsed": 25256427520,
"memoryFree": 513376256,
"memoryUsagePercent": 98.0078379313151,
"memoryEfficiency": 1.9921620686849053,
"cpuCount": 14,
"cpuLoad": 0.15576171875,
"platform": "darwin",
"uptime": 5183470
},
{
"timestamp": 1756497346620,
"memoryTotal": 25769803776,
"memoryUsed": 25464700928,
"memoryFree": 305102848,
"memoryUsagePercent": 98.81604512532553,
"memoryEfficiency": 1.1839548746744697,
"cpuCount": 14,
"cpuLoad": 0.21369280133928573,
"platform": "darwin",
"uptime": 5183500
},
{
"timestamp": 1756497376620,
"memoryTotal": 25769803776,
"memoryUsed": 25632653312,
"memoryFree": 137150464,
"memoryUsagePercent": 99.46778615315756,
"memoryEfficiency": 0.5322138468424384,
"cpuCount": 14,
"cpuLoad": 0.201904296875,
"platform": "darwin",
"uptime": 5183530
},
{
"timestamp": 1756497406621,
"memoryTotal": 25769803776,
"memoryUsed": 25066160128,
"memoryFree": 703643648,
"memoryUsagePercent": 97.26950327555338,
"memoryEfficiency": 2.730496724446624,
"cpuCount": 14,
"cpuLoad": 0.192626953125,
"platform": "darwin",
"uptime": 5183560
},
{
"timestamp": 1756497436623,
"memoryTotal": 25769803776,
"memoryUsed": 25574244352,
"memoryFree": 195559424,
"memoryUsagePercent": 99.24112955729166,
"memoryEfficiency": 0.7588704427083428,
"cpuCount": 14,
"cpuLoad": 0.19761439732142858,
"platform": "darwin",
"uptime": 5183590
},
{
"timestamp": 1756497466624,
"memoryTotal": 25769803776,
"memoryUsed": 25427623936,
"memoryFree": 342179840,
"memoryUsagePercent": 98.67216746012369,
"memoryEfficiency": 1.3278325398763116,
"cpuCount": 14,
"cpuLoad": 0.20005580357142858,
"platform": "darwin",
"uptime": 5183620
},
{
"timestamp": 1756497496626,
"memoryTotal": 25769803776,
"memoryUsed": 25596116992,
"memoryFree": 173686784,
"memoryUsagePercent": 99.32600657145181,
"memoryEfficiency": 0.6739934285481866,
"cpuCount": 14,
"cpuLoad": 0.19367327008928573,
"platform": "darwin",
"uptime": 5183650
},
{
"timestamp": 1756497526626,
"memoryTotal": 25769803776,
"memoryUsed": 25207111680,
"memoryFree": 562692096,
"memoryUsagePercent": 97.81646728515625,
"memoryEfficiency": 2.18353271484375,
"cpuCount": 14,
"cpuLoad": 0.15276227678571427,
"platform": "darwin",
"uptime": 5183680
},
{
"timestamp": 1756497556628,
"memoryTotal": 25769803776,
"memoryUsed": 25510182912,
"memoryFree": 259620864,
"memoryUsagePercent": 98.99253845214844,
"memoryEfficiency": 1.0074615478515625,
"cpuCount": 14,
"cpuLoad": 0.13654436383928573,
"platform": "darwin",
"uptime": 5183710
},
{
"timestamp": 1756497586629,
"memoryTotal": 25769803776,
"memoryUsed": 25638223872,
"memoryFree": 131579904,
"memoryUsagePercent": 99.4894027709961,
"memoryEfficiency": 0.5105972290039062,
"cpuCount": 14,
"cpuLoad": 0.13361467633928573,
"platform": "darwin",
"uptime": 5183740
},
{
"timestamp": 1756497616629,
"memoryTotal": 25769803776,
"memoryUsed": 25115246592,
"memoryFree": 654557184,
"memoryUsagePercent": 97.4599838256836,
"memoryEfficiency": 2.5400161743164062,
"cpuCount": 14,
"cpuLoad": 0.12283761160714286,
"platform": "darwin",
"uptime": 5183770
},
{
"timestamp": 1756497646631,
"memoryTotal": 25769803776,
"memoryUsed": 25625640960,
"memoryFree": 144162816,
"memoryUsagePercent": 99.4405746459961,
"memoryEfficiency": 0.5594253540039062,
"cpuCount": 14,
"cpuLoad": 0.11844308035714286,
"platform": "darwin",
"uptime": 5183800
},
{
"timestamp": 1756497676631,
"memoryTotal": 25769803776,
"memoryUsed": 25629868032,
"memoryFree": 139935744,
"memoryUsagePercent": 99.45697784423828,
"memoryEfficiency": 0.5430221557617188,
"cpuCount": 14,
"cpuLoad": 0.10808454241071429,
"platform": "darwin",
"uptime": 5183830
},
{
"timestamp": 1756497706632,
"memoryTotal": 25769803776,
"memoryUsed": 25579716608,
"memoryFree": 190087168,
"memoryUsagePercent": 99.26236470540366,
"memoryEfficiency": 0.7376352945963447,
"cpuCount": 14,
"cpuLoad": 0.11753627232142858,
"platform": "darwin",
"uptime": 5183860
},
{
"timestamp": 1756497736633,
"memoryTotal": 25769803776,
"memoryUsed": 25617006592,
"memoryFree": 152797184,
"memoryUsagePercent": 99.40706888834634,
"memoryEfficiency": 0.5929311116536553,
"cpuCount": 14,
"cpuLoad": 0.10857282366071429,
"platform": "darwin",
"uptime": 5183890
},
{
"timestamp": 1756497766634,
"memoryTotal": 25769803776,
"memoryUsed": 25662226432,
"memoryFree": 107577344,
"memoryUsagePercent": 99.5825449625651,
"memoryEfficiency": 0.4174550374349053,
"cpuCount": 14,
"cpuLoad": 0.108642578125,
"platform": "darwin",
"uptime": 5183920
},
{
"timestamp": 1756497796634,
"memoryTotal": 25769803776,
"memoryUsed": 25651675136,
"memoryFree": 118128640,
"memoryUsagePercent": 99.5416005452474,
"memoryEfficiency": 0.4583994547525947,
"cpuCount": 14,
"cpuLoad": 0.15876116071428573,
"platform": "darwin",
"uptime": 5183950
},
{
"timestamp": 1756497826636,
"memoryTotal": 25769803776,
"memoryUsed": 25676709888,
"memoryFree": 93093888,
"memoryUsagePercent": 99.63874816894531,
"memoryEfficiency": 0.3612518310546875,
"cpuCount": 14,
"cpuLoad": 0.16842215401785715,
"platform": "darwin",
"uptime": 5183980
},
{
"timestamp": 1756497856636,
"memoryTotal": 25769803776,
"memoryUsed": 25665978368,
"memoryFree": 103825408,
"memoryUsagePercent": 99.59710439046225,
"memoryEfficiency": 0.40289560953775094,
"cpuCount": 14,
"cpuLoad": 0.19695172991071427,
"platform": "darwin",
"uptime": 5184010
},
{
"timestamp": 1756497886638,
"memoryTotal": 25769803776,
"memoryUsed": 25674203136,
"memoryFree": 95600640,
"memoryUsagePercent": 99.62902069091797,
"memoryEfficiency": 0.37097930908203125,
"cpuCount": 14,
"cpuLoad": 0.21759905133928573,
"platform": "darwin",
"uptime": 5184040
},
{
"timestamp": 1756497916639,
"memoryTotal": 25769803776,
"memoryUsed": 25173065728,
"memoryFree": 596738048,
"memoryUsagePercent": 97.6843516031901,
"memoryEfficiency": 2.3156483968099053,
"cpuCount": 14,
"cpuLoad": 0.20626395089285715,
"platform": "darwin",
"uptime": 5184070
},
{
"timestamp": 1756497946640,
"memoryTotal": 25769803776,
"memoryUsed": 25338855424,
"memoryFree": 430948352,
"memoryUsagePercent": 98.32770029703775,
"memoryEfficiency": 1.672299702962249,
"cpuCount": 14,
"cpuLoad": 0.24553571428571427,
"platform": "darwin",
"uptime": 5184100
}
]
-114
View File
@@ -1,114 +0,0 @@
kind: pipeline
type: docker
name: default
steps:
# Build Backend Docker Image
- name: build-backend
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-backend
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
- ${DRONE_BRANCH}-latest
dockerfile: backend/Dockerfile
context: backend/
registry: registry.local.nothaft.cloud
build_args:
- VERSION=${DRONE_TAG:-dev}
# Build Frontend Docker Image
- name: build-frontend
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-frontend
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
- ${DRONE_BRANCH}-latest
dockerfile: frontend/Dockerfile
context: frontend/
registry: registry.local.nothaft.cloud
build_args:
- VERSION=${DRONE_TAG:-dev}
- VITE_API_URL=${VITE_API_URL:-/api}
trigger:
branch:
- main
- develop
event:
- push
- pull_request
---
kind: pipeline
type: docker
name: release
steps:
# Build Backend Release
- name: build-backend-release
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-backend
tags:
- ${DRONE_TAG}
- latest
dockerfile: backend/Dockerfile
context: backend/
registry: registry.local.nothaft.cloud
# Build Frontend Release
- name: build-frontend-release
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-frontend
tags:
- ${DRONE_TAG}
- latest
dockerfile: frontend/Dockerfile
context: frontend/
registry: registry.local.nothaft.cloud
# -------- NEW: Publish Docker images to GitHub Container Registry --------
- name: push-backend-ghcr
image: plugins/docker
settings:
repo: ghcr.io/the-luap/picpeak-backend
tags:
- ${DRONE_TAG}
- latest
dockerfile: backend/Dockerfile
context: backend/
registry: ghcr.io
username:
from_secret: GITHUB_USERNAME
password:
from_secret: GITHUB_TOKEN
build_args:
- VERSION=${DRONE_TAG}
- name: push-frontend-ghcr
image: plugins/docker
settings:
repo: ghcr.io/the-luap/picpeak-frontend
tags:
- ${DRONE_TAG}
- latest
dockerfile: frontend/Dockerfile
context: frontend/
registry: ghcr.io
username:
from_secret: GITHUB_USERNAME
password:
from_secret: GITHUB_TOKEN
build_args:
- VERSION=${DRONE_TAG}
- VITE_API_URL=${VITE_API_URL:-/api}
trigger:
event:
- tag
+27 -3
View File
@@ -34,9 +34,15 @@ SMTP_PASS=your-app-specific-password
EMAIL_FROM=noreply@yourdomain.com
# Application URLs
# Use full origin with scheme, no trailing slash.
# Admin UI is served by the frontend at /admin.
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com:3001
VITE_API_URL=https://yourdomain.com:3001/api
ADMIN_URL=https://yourdomain.com
# 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.
VITE_API_URL=/api
# Port Configuration (optional)
# BACKEND_PORT=3001
@@ -47,7 +53,25 @@ VITE_API_URL=https://yourdomain.com:3001/api
# Timezone
TZ=UTC
# Runtime user mapping for Docker (optional)
# Set these to your host user's UID/GID to avoid permission issues on bind mounts.
# Run `id -u` and `id -g` on host to get values. Defaults to 1001.
PUID=1001
PGID=1001
# Analytics (Optional - Umami)
VITE_UMAMI_URL=
VITE_UMAMI_WEBSITE_ID=
VITE_UMAMI_SHARE_URL=
VITE_UMAMI_SHARE_URL=
# Storage variables (host paths)
# These control where data is stored on the host. Defaults are local folders.
APP_STORAGE=./storage
APP_DATA=./data
LOGS=./logs
# Note on FRONTEND_API_URL (documentation only):
# When using pre-built frontend images, runtime env vars cannot override the built JS.
# Do NOT rely on FRONTEND_API_URL in Compose. Instead, keep VITE_API_URL=/api and
# let the frontend Nginx proxy /api to the backend. Only if you rebuild the frontend
# should you change VITE_API_URL at build time.
-134
View File
@@ -1,134 +0,0 @@
name: Mirror to GitHub
on:
push:
branches:
- main
workflow_dispatch: # Allow manual triggering
jobs:
mirror:
runs-on: ubuntu-latest
# Note: For GitHub fine-grained tokens, ensure the token has:
# - Repository access to the-luap/picpeak
# - Repository permissions: Contents (Read and Write), Metadata (Read)
# For classic tokens: repo scope is sufficient
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0 # Full history for proper mirroring
- name: Setup Git
run: |
git config --global user.name "the-luap"
git config --global user.email "paul-nothaft@hotmail.de"
- name: Remove sensitive files and directories
run: |
echo "Current files before cleanup:"
ls -la | head -10 || true
echo "..."
# Remove sensitive files/directories if they exist
echo "Removing sensitive files..."
rm -rf .gitea/ || true
rm -rf scripts/ || true
rm -rf .drone* || true
rm -rf photo-sharing-prd.md || true
rm -rf CLAUDE.md || true
rm -rf storage/ || true
rm -rf events/ || true
rm -rf .playwright-mcp/
rm -rf .swarm || true
rm -rf .claude-flow || true
echo "Sensitive files removal completed"
# Add and commit the cleanup if there are changes
git add -A
if ! git diff --cached --quiet; then
git commit -m "chore: remove sensitive files for GitHub mirror"
echo "✅ Committed cleanup of sensitive files"
else
echo "✅ No sensitive files to remove"
fi
echo "Final file structure (top level):"
ls -la | head -10 || true
- name: Check GitHub token
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
run: |
if [ -z "$GITHUBTOKEN" ]; then
echo "ERROR: GITHUBTOKEN secret is not set!"
echo "Please add a GitHub Personal Access Token as a secret named GITHUBTOKEN"
echo ""
echo "For fine-grained tokens:"
echo " - Go to GitHub Settings > Developer settings > Personal access tokens > Fine-grained tokens"
echo " - Create token with repository access to the-luap/picpeak"
echo " - Grant permissions: Contents (Read and Write), Metadata (Read)"
echo ""
echo "For classic tokens:"
echo " - Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic)"
echo " - Create token with 'repo' scope"
exit 1
else
echo "✅ GitHub token is available (length: ${#GITHUBTOKEN})"
# Try to detect token type (fine-grained tokens are typically longer)
if [ ${#GITHUBTOKEN} -gt 80 ]; then
echo "📌 Token appears to be a fine-grained personal access token"
else
echo "📌 Token appears to be a classic personal access token"
fi
fi
- name: Push to GitHub
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
GIT_TRACE: 1 # Enable Git trace for debugging if needed
run: |
# Remove existing github remote if it exists
git remote remove github || true
# Configure Git to use the token for authentication
# This method works for both classic and fine-grained tokens
git config --global url."https://the-luap:${GITHUBTOKEN}@github.com/".insteadOf "https://github.com/"
# Add GitHub remote (clean URL without credentials)
git remote add github https://github.com/the-luap/picpeak.git
# Verify remote was added
echo "GitHub remote configuration:"
git remote -v
# Push to GitHub main branch with error handling
echo "Pushing to GitHub..."
if git push github main --force 2>&1; then
echo "✅ Push to GitHub completed successfully!"
else
echo "❌ Push to GitHub failed!"
echo ""
echo "Common issues and solutions:"
echo "1. Token permissions: Ensure your token has 'Contents: write' permission"
echo "2. Token expiration: Check if your token has expired"
echo "3. Repository access: Verify the token has access to the-luap/picpeak repository"
echo ""
echo "For fine-grained tokens, required permissions:"
echo " - Repository access: the-luap/picpeak"
echo " - Repository permissions: Contents (Read and Write), Metadata (Read)"
echo ""
echo "For classic tokens, required scope: 'repo'"
exit 1
fi
# Clean up the git config after push
git config --global --unset url."https://the-luap:${GITHUBTOKEN}@github.com/".insteadOf
- name: Workflow completed
run: |
echo "✅ Mirror to GitHub workflow completed successfully!"
echo "📊 Repository mirrored to: https://github.com/the-luap/picpeak"
echo "🔒 Sensitive files have been removed from the mirror"
-52
View File
@@ -1,52 +0,0 @@
name: Test and Lint
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
backend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install backend dependencies
working-directory: ./backend
run: npm ci
- name: Run backend linting
working-directory: ./backend
run: npm run lint || true # Continue on lint errors for now
- name: Run backend tests
working-directory: ./backend
run: npm test || true # Continue on test failures for now
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install frontend dependencies
working-directory: ./frontend
run: npm ci --legacy-peer-deps
- name: Run frontend linting
working-directory: ./frontend
run: npm run lint || true # Continue on lint errors for now
- name: Build frontend
working-directory: ./frontend
run: npm run build
-267
View File
@@ -1,267 +0,0 @@
name: Version and Release
on:
push:
branches: [ main ]
paths-ignore:
- '**.md'
- '.gitea/**'
- '.drone.yml'
jobs:
version-bump:
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.version.outputs.new_version }}
version_changed: ${{ steps.version.outputs.version_changed }}
component_changed: ${{ steps.version.outputs.component_changed }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ secrets.GITEA_TOKEN || github.token }}
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Configure Git
run: |
git config --global user.name 'Gitea Actions Bot'
git config --global user.email 'actions@gitea.local'
- name: Detect changes and bump version
id: version
run: |
set -e # Exit on error
echo "=== Debug Info ==="
echo "GitHub event before: ${{ github.event.before }}"
echo "GitHub SHA: ${{ github.sha }}"
echo "Current directory: $(pwd)"
echo "Git log (last 5): $(git log --oneline -5)"
# Get the commit range for changed files
if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ] && [ "${{ github.event.before }}" != "" ]; then
COMMIT_RANGE="${{ github.event.before }}..${{ github.sha }}"
echo "Using commit range: $COMMIT_RANGE"
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
else
# First commit or no previous commit, check against HEAD~1 if it exists
if git rev-parse HEAD~1 >/dev/null 2>&1; then
COMMIT_RANGE="HEAD~1..HEAD"
echo "Using commit range: $COMMIT_RANGE"
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
else
echo "First commit detected, checking all files"
CHANGED_FILES=$(git ls-files)
fi
fi
echo "Changed files:"
echo "$CHANGED_FILES"
# Check what changed (using echo to pipe to grep to avoid grep exit codes)
BACKEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^backend/' || echo "0")
FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^frontend/' || echo "0")
ROOT_CHANGED=$(echo "$CHANGED_FILES" | grep -c -E '^(package\.json|docker-compose|Dockerfile|scripts/)' || echo "0")
echo "Backend files changed: $BACKEND_CHANGED"
echo "Frontend files changed: $FRONTEND_CHANGED"
echo "Root files changed: $ROOT_CHANGED"
# Get current versions
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0")
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0")
echo "Current backend version: $BACKEND_VERSION"
echo "Current frontend version: $FRONTEND_VERSION"
# Determine what to update based on changes
BACKEND_UPDATE=false
FRONTEND_UPDATE=false
COMPONENT_CHANGED="none"
if [ "$ROOT_CHANGED" -gt 0 ]; then
# Root changes affect both components
BACKEND_UPDATE=true
FRONTEND_UPDATE=true
COMPONENT_CHANGED="both"
SOURCE_VERSION=$BACKEND_VERSION
echo "Root changes detected - updating both components"
elif [ "$BACKEND_CHANGED" -gt 0 ] && [ "$FRONTEND_CHANGED" -gt 0 ]; then
# Both components changed
BACKEND_UPDATE=true
FRONTEND_UPDATE=true
COMPONENT_CHANGED="both"
# Use the higher version as source
if [ "$(printf '%s\n' "$BACKEND_VERSION" "$FRONTEND_VERSION" | sort -V | tail -n1)" = "$BACKEND_VERSION" ]; then
SOURCE_VERSION=$BACKEND_VERSION
else
SOURCE_VERSION=$FRONTEND_VERSION
fi
echo "Both backend and frontend changed - updating both"
elif [ "$BACKEND_CHANGED" -gt 0 ]; then
# Only backend changed
BACKEND_UPDATE=true
COMPONENT_CHANGED="backend"
SOURCE_VERSION=$BACKEND_VERSION
echo "Only backend changed - updating backend"
elif [ "$FRONTEND_CHANGED" -gt 0 ]; then
# Only frontend changed
FRONTEND_UPDATE=true
COMPONENT_CHANGED="frontend"
SOURCE_VERSION=$FRONTEND_VERSION
echo "Only frontend changed - updating frontend"
else
echo "No relevant changes detected"
echo "version_changed=false" >> $GITHUB_OUTPUT
echo "component_changed=none" >> $GITHUB_OUTPUT
echo "new_version=" >> $GITHUB_OUTPUT
exit 0
fi
echo "Component changed: $COMPONENT_CHANGED"
echo "Source version: $SOURCE_VERSION"
echo "Backend update: $BACKEND_UPDATE"
echo "Frontend update: $FRONTEND_UPDATE"
# Calculate new version
IFS='.' read -r -a version_parts <<< "$SOURCE_VERSION"
MAJOR="${version_parts[0]}"
MINOR="${version_parts[1]}"
PATCH="${version_parts[2]}"
# Increment patch version
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
# Update versions in package.json files
if [ "$BACKEND_UPDATE" = true ]; then
echo "Updating backend version to $NEW_VERSION"
cd backend && npm version $NEW_VERSION --no-git-tag-version
cd ..
fi
if [ "$FRONTEND_UPDATE" = true ]; then
echo "Updating frontend version to $NEW_VERSION"
cd frontend && npm version $NEW_VERSION --no-git-tag-version
cd ..
fi
# Check if there are changes to commit
if [[ -n $(git status --porcelain) ]]; then
echo "version_changed=true" >> $GITHUB_OUTPUT
else
echo "version_changed=false" >> $GITHUB_OUTPUT
fi
- name: Commit version bump
if: steps.version.outputs.version_changed == 'true'
run: |
set -e # Exit on any error
# First, ensure we have the latest changes
echo "Fetching latest changes..."
git fetch origin main
# Check if we're behind and need to update
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [ "$LOCAL" != "$REMOTE" ]; then
echo "Local is behind remote, pulling changes..."
git pull origin main --no-rebase
fi
COMPONENT="${{ steps.version.outputs.component_changed }}"
if [ "$COMPONENT" = "both" ]; then
git add backend/package.json backend/package-lock.json frontend/package.json frontend/package-lock.json
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)"
elif [ "$COMPONENT" = "backend" ]; then
git add backend/package.json backend/package-lock.json
git commit -m "chore: bump backend version to ${{ steps.version.outputs.new_version }}"
elif [ "$COMPONENT" = "frontend" ]; then
git add frontend/package.json frontend/package-lock.json
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
fi
# Pull latest changes before pushing to avoid conflicts
echo "Pulling latest changes from origin/main..."
if ! git pull --rebase origin main; then
echo "Rebase failed, attempting to resolve..."
# If rebase fails, abort and try a regular merge
git rebase --abort || true
git pull origin main --no-rebase
fi
# Push the changes with retry logic
echo "Pushing version bump..."
PUSH_SUCCESS=false
for i in 1 2 3; do
echo "Push attempt $i of 3..."
# Try to push
if git push origin main 2>&1; then
echo "Successfully pushed version bump on attempt $i"
PUSH_SUCCESS=true
break
else
echo "Push failed on attempt $i"
if [ $i -lt 3 ]; then
echo "Waiting 5 seconds before retry..."
sleep 5
echo "Pulling latest changes..."
git fetch origin main
# Try rebase first, fall back to merge
if ! git rebase origin/main; then
echo "Rebase failed, trying merge..."
git rebase --abort 2>/dev/null || true
git pull origin main --no-rebase
fi
fi
fi
done
if [ "$PUSH_SUCCESS" = "false" ]; then
echo "ERROR: Failed to push after 3 attempts"
exit 1
fi
- name: Create Git tag
if: steps.version.outputs.version_changed == 'true'
run: |
COMPONENT="${{ steps.version.outputs.component_changed }}"
if [ "$COMPONENT" = "both" ]; then
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend + frontend)"
elif [ "$COMPONENT" = "backend" ]; then
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend)"
elif [ "$COMPONENT" = "frontend" ]; then
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (frontend)"
fi
git tag -a "v${{ steps.version.outputs.new_version }}" -m "$TAG_MESSAGE"
git push origin "v${{ steps.version.outputs.new_version }}"
trigger-drone:
needs: version-bump
if: needs.version-bump.outputs.version_changed == 'true'
runs-on: ubuntu-latest
steps:
- name: Trigger Drone Build
run: |
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
echo "Drone will automatically trigger on the new tag"
# Drone CI will automatically trigger on the tag push event
+46 -46
View File
@@ -31,18 +31,31 @@ jobs:
contents: read
packages: write
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine build platforms
id: platforms
run: |
# For PRs, build only amd64 to avoid QEMU emulation issues with Sharp
# For main/develop/tags, build multi-arch
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
else
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
platforms: ${{ steps.platforms.outputs.platforms }}
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
@@ -65,7 +78,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix={{branch}}-,format=short
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Backend Docker image
@@ -73,10 +86,11 @@ jobs:
with:
context: ./backend
file: ./backend/Dockerfile
push: ${{ github.event_name != 'pull_request' || github.event.inputs.push == 'true' }}
# Always build; only push when registry login succeeded
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
platforms: linux/amd64,linux/arm64
platforms: ${{ steps.platforms.outputs.platforms }}
cache-from: type=gha,scope=backend
cache-to: type=gha,mode=max,scope=backend
build-args: |
@@ -86,7 +100,7 @@ jobs:
VERSION=${{ steps.meta-backend.outputs.version }}
- name: Run Trivy vulnerability scanner
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
@@ -96,7 +110,7 @@ jobs:
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-backend.sarif'
@@ -108,18 +122,31 @@ jobs:
contents: read
packages: write
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine build platforms
id: platforms
run: |
# For PRs, build only amd64 to avoid QEMU emulation issues
# For main/develop/tags, build multi-arch
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
else
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
platforms: ${{ steps.platforms.outputs.platforms }}
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
@@ -142,7 +169,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix={{branch}}-,format=short
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Frontend Docker image
@@ -150,10 +177,11 @@ jobs:
with:
context: ./frontend
file: ./frontend/Dockerfile
push: ${{ github.event_name != 'pull_request' || github.event.inputs.push == 'true' }}
# Always build; only push when registry login succeeded
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
platforms: linux/amd64,linux/arm64
platforms: ${{ steps.platforms.outputs.platforms }}
cache-from: type=gha,scope=frontend
cache-to: type=gha,mode=max,scope=frontend
build-args: |
@@ -163,7 +191,7 @@ jobs:
VERSION=${{ steps.meta-frontend.outputs.version }}
- name: Run Trivy vulnerability scanner
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: aquasecurity/trivy-action@master
with:
image-ref: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
@@ -173,43 +201,15 @@ jobs:
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request'
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: 'trivy-frontend.sarif'
category: 'frontend-vulnerabilities'
publish-manifest:
needs: [build-backend, build-frontend]
if: github.event_name == 'release' || (github.event_name == 'push' && github.ref == 'refs/heads/main')
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Create and push multi-arch manifest for Backend
run: |
docker manifest create \
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:latest \
--amend ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:latest-amd64 \
--amend ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:latest-arm64
docker manifest push ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:latest
- name: Create and push multi-arch manifest for Frontend
run: |
docker manifest create \
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:latest \
--amend ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:latest-amd64 \
--amend ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:latest-arm64
docker manifest push ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:latest
# Note: The publish-manifest job is not needed since docker/build-push-action@v5
# automatically creates multi-arch manifests when building for multiple platforms.
# The images are already properly tagged and include all architectures.
summary:
needs: [build-backend, build-frontend]
@@ -248,4 +248,4 @@ jobs:
echo "- PR number (for pull requests)" >> $GITHUB_STEP_SUMMARY
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
echo "- Short SHA with branch prefix" >> $GITHUB_STEP_SUMMARY
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
+10
View File
@@ -72,3 +72,13 @@ logs/
storage/
data/
certbot/
# Ignore local contributor guide copy
AGENTS.md
CLAUDE.md
# Local artifacts from browser tooling
.playwright-mcp/
# Local SQLite files in backend
backend/*.sqlite*
Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 209 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 174 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
-386
View File
@@ -1,386 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Product Overview
A secure photo sharing platform designed for weddings and events, enabling photographers to share time-limited, password-protected galleries. The platform features automatic expiration, archiving, and a scrappbook.de-inspired modern, minimalist UI.
## Architecture Overview
- **Backend**: Node.js/Express API with SQLite/PostgreSQL, file-based photo storage
- **Frontend**: React SPA with scrappbook.de-style design (requires implementation)
- **Storage**: File-based with active/archived separation
- **Services**: Background workers for email, archiving, file watching, and expiration monitoring
- **Analytics**: Umami integration for engagement tracking
## Essential Commands
### Backend Development
```bash
cd backend
npm install # Install dependencies
npm run migrate # Initialize database schema
npm run dev # Start with hot-reload (port 3001)
npm test # Run Jest tests
npm run lint # ESLint checks
```
### Running a Single Test
```bash
cd backend
npm test -- path/to/test.test.js
npm test -- --testNamePattern="test name"
```
### Production Deployment
See [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) for comprehensive deployment instructions including:
- Docker Compose deployment
- PM2 deployment
- Manual installation
- Non-nginx deployment options
- SSL/HTTPS setup
- Troubleshooting guide
**⚠️ CRITICAL PRODUCTION NOTICE:**
- Production runs on a SEPARATE SERVER - never assume local changes affect production
- ALWAYS request production server details before any troubleshooting
- NO trial-and-error approaches in production - data loss is unacceptable
- Every change must be thoroughly analyzed and tested locally first
## Key Product Requirements (from PRD)
### Core Features
1. **File-Based System**: Drop photos in folders → automatic gallery creation
2. **Automatic Expiration**: Default 30 days, with 7-day warning emails
3. **Password Protection**: Secure access with customizable passwords
4. **Automatic Archiving**: ZIP compression and storage after expiration
5. **Email Notifications**: Creation, warning, and expiration notifications
6. **Analytics**: Umami tracking for views, downloads, and engagement
### Folder Structure
```
/events/
├── active/
│ ├── wedding-smith-jones-2024-06-15/
│ │ ├── collages/
│ │ └── individual/
│ └── birthday-emma-2024-07-20/
└── archived/
└── wedding-smith-jones-2024-06-15.zip
```
## Frontend Implementation Requirements
### Design Style (scrappbook.de-inspired)
- **Color Palette**: Primary green (#5C8762), neutral backgrounds
- **Typography**: Clean, modern sans-serif (Noto Sans or similar)
- **Layout**: Minimalist, modular sections with grid-based photo displays
- **Aesthetic**: Professional yet approachable, photographer-focused
### Key Frontend Components to Build
1. **Landing Page**: Password entry with event preview
2. **Gallery View**:
- Responsive photo grid with lazy loading
- Toggle between collages/individual photos
- Prominent expiration banner
- Download urgency indicators
3. **Photo Lightbox**: Full-screen viewing with zoom
4. **Mobile-First**: Responsive design with touch gestures
5. **Personalization**: Dynamic theming per event type
### User Experience Priorities
- Clear expiration warnings (sticky banner)
- One-click "Download All" for urgent galleries
- Smooth image loading with skeleton screens
- Intuitive navigation between photo categories
- Professional presentation matching photographer branding
## Key Architecture Patterns
### Authentication Flow
- JWT-based with separate tokens for admin and gallery access
- Gallery tokens include event-specific claims
- Auth middleware: `backend/src/middleware/auth.js`
- `adminAuth` - Admin panel protection
- `photoAuth` - Protected photo access
- `verifyGalleryAccess` - Gallery-specific validation
### Database Schema (Knex/SQLite)
Main tables:
- `events` - Gallery metadata with expiration, custom messages, themes
- `photos` - Photo records linked to events
- `access_logs` - IP-based usage tracking
- `email_queue` - Async email processing
- `admin_users` - Admin authentication
### Service Architecture
Background services run as separate processes:
- **emailService**: Processes email queue with retry logic
- **archiveService**: Creates ZIP archives of expired events
- **expirationChecker**: Cron job for expiration warnings
- **fileWatcher**: Monitors for new photo uploads
- **backupService**: Scheduled backups with checksum-based change detection
### API Structure
- `/api/admin/*` - Admin panel endpoints (requires adminAuth)
- `/api/gallery/*` - Public gallery endpoints
- `/api/auth/*` - Authentication endpoints
- Rate limiting: 100 req/15min (general), 5 req/15min (auth)
## Critical Implementation Notes
1. **Security**: All gallery access requires valid JWT with event-specific claims
2. **Expiration**: Events auto-expire based on `expires_at`, with 7-day email warnings
3. **Email Queue**: Async processing with retry logic, check `email_queue` table
4. **File Processing**: Sharp library for thumbnail generation (300x300)
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
## Troubleshooting Guidelines
### Before ANY Production Troubleshooting:
1. **ALWAYS request specific details**:
- Production server URL/IP
- Current error messages/logs
- Recent changes or deployments
- Affected users/galleries
- Time of issue occurrence
2. **Thorough Analysis Required**:
- Use detailed thinking/analysis for EVERY troubleshooting task
- Review all related code before suggesting changes
- Consider all potential side effects
- Never make assumptions about production environment
3. **Safe Troubleshooting Steps**:
- First, reproduce issue in local/dev environment
- Analyze logs without modifying production
- Create detailed action plan before any changes
- Always have rollback strategy ready
- Document every step taken
### Common Issues & Safe Approaches:
- **Email not sending**: Check email_queue table, SMTP settings, service status
- **Photos not loading**: Verify file permissions, storage paths, nginx config
- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs
- **Performance problems**: Analyze with monitoring tools first, never experiment
### Data Safety Rules:
- NEVER delete or modify production data without explicit backup confirmation
- ALWAYS verify backups exist before any data operations
- NO direct database modifications without transaction safety
- Log all actions for audit trail
## Environment Variables
### Backend (.env)
- `JWT_SECRET` - Token signing
- `ADMIN_URL`, `FRONTEND_URL` - CORS origins
- `SMTP_*` - Email configuration
- `DB_*` - PostgreSQL credentials (production)
- `UMAMI_URL` - Umami instance URL (for server-side tracking)
- `UMAMI_WEBSITE_ID` - Website ID from Umami
### Frontend (.env)
- `VITE_API_URL` - Backend API URL
- `VITE_UMAMI_URL` - Umami analytics URL
- `VITE_UMAMI_WEBSITE_ID` - Website ID from Umami
- `VITE_UMAMI_SHARE_URL` - (Optional) Public share URL for embedded dashboard
## Testing Approach
- Jest with Supertest for API testing
- Test files in `__tests__` directories
- Database migrations run before tests
- Mock email sending in tests
## Umami Analytics Integration
The frontend includes comprehensive Umami analytics integration for tracking user behavior and gallery performance.
### Tracked Events:
- **Gallery Events**:
- `gallery_password_entry` - Password attempts (success/failure)
- `gallery_photo_view` - Individual photo views
- `gallery_photo_download` - Single photo downloads
- `gallery_bulk_download` - Bulk/all photo downloads
- `gallery_expired` - Expired gallery access attempts
- **Admin Events**:
- `admin_login` - Admin authentication
- `admin_event_created` - New event creation
- `admin_event_archived` - Event archiving
- `admin_event_deleted` - Event deletion
- `admin_settings_updated` - Settings changes
- **User Behavior**:
- Search queries (with debouncing)
- Expiration warning views
- Page views with automatic tracking
### Setup:
1. Install Umami (self-hosted or cloud)
2. Create a website in Umami dashboard
3. Set environment variables:
```
VITE_UMAMI_URL=https://your-umami-instance.com
VITE_UMAMI_WEBSITE_ID=your-website-id
VITE_UMAMI_SHARE_URL=https://your-umami-instance.com/share/...
```
### Analytics Dashboard:
- Admin panel includes analytics page at `/admin/analytics`
- Summary view with key metrics
- Option to embed full Umami dashboard
- Real-time event tracking
## Accessibility & Performance Features
### Accessibility (WCAG 2.1 AA Compliance)
- **Error Boundaries**: Graceful error handling with recovery options
- **Skip Links**: Skip to main content for keyboard navigation
- **ARIA Labels**: Proper labeling for screen readers
- **Focus Management**: Focus trap in modals, visible focus indicators
- **Keyboard Navigation**: Full keyboard support in gallery lightbox (arrows, escape, +/-, d for download)
- **Loading States**: Skeleton screens instead of spinners for better UX
- **Offline Support**: Visual indicator when offline
- **Form Validation**: Accessible error messages with aria-describedby
### Performance Optimizations
- **Lazy Loading**: Images load on scroll with Intersection Observer
- **Skeleton Screens**: Instant visual feedback during loading
- **Error Recovery**: Component-level error boundaries prevent full page crashes
- **Optimistic Updates**: Immediate UI updates with background sync
- **Debounced Search**: Prevents excessive API calls
- **Analytics**: Non-blocking Umami integration
### Component Library Enhancements
- `<ErrorBoundary>` - Catches and displays errors gracefully
- `<PageErrorBoundary>` - Full-page error recovery
- `<Skeleton>` - Flexible skeleton loader with variants
- `<OfflineIndicator>` - Network status monitoring
- `<SkipLink>` - Accessibility navigation
- `useFocusTrap` - Modal focus management hook
- `useOnlineStatus` - Network status hook
## Theme System & Branding
### Theme Features
- **Dynamic Theming**: CSS variables for runtime theme switching
- **Preset Themes**: Default, Wedding, Birthday, Corporate, Minimal
- **Customization Options**:
- Primary/Accent/Background/Text colors
- Font family selection
- Border radius (none, sm, md, lg)
- Custom logo upload
- Custom CSS injection
- **Event-Specific Themes**: Override global theme per gallery
- **Live Preview**: Real-time theme changes in admin panel
### Theme Context API
```typescript
const { theme, setTheme, setThemeByName } = useTheme();
```
### Branding Settings
- Company name, tagline, and support email
- Custom footer text
- Optional watermarking on downloads
- Logo upload for gallery header
### CSS Variables
```css
--color-primary: #5C8762;
--color-primary-light: #7aa583;
--color-primary-dark: #4a6f4f;
--color-accent: #22c55e;
--color-background: #fafafa;
--color-text: #171717;
--font-family: 'Inter', sans-serif;
--border-radius: 0.5rem;
```
## Backup Service
### Overview
The backup service provides automated, scheduled backups of all photo data with checksum-based change detection to minimize transfer overhead.
### Features
- **Multiple Destinations**: Local directory, remote server (rsync), S3-compatible storage
- **Change Detection**: SHA256 checksums track file changes, only modified files are backed up
- **Scheduled Execution**: Configurable cron-based scheduling (default: 2 AM daily)
- **Email Notifications**: Alerts on backup failure, optional success notifications
- **Retention Management**: Automatic cleanup of old backup runs based on retention policy
- **Progress Tracking**: Database storage of backup history, file states, and statistics
### Configuration
Backup settings are stored in `app_settings` table with `backup_` prefix:
- `backup_enabled`: Enable/disable the service
- `backup_schedule`: Cron expression (e.g., '0 2 * * *')
- `backup_destination_type`: 'local', 'rsync', or 's3'
- `backup_retention_days`: How long to keep backup history
- `backup_include_archived`: Whether to backup archived events
- `backup_exclude_patterns`: File patterns to exclude
### API Endpoints
- `GET /api/admin/backup/config` - Get current configuration
- `PUT /api/admin/backup/config` - Update configuration
- `GET /api/admin/backup/status` - Get backup status and history
- `POST /api/admin/backup/run` - Trigger manual backup
- `POST /api/admin/backup/test-connection` - Test destination connectivity
### Testing
Run backup service test: `npm run test-backup`
### Database Tables
- `backup_runs`: Tracks each backup execution with statistics
- `backup_file_states`: Stores file checksums for change detection
## Thumbnail Generation
### Square Thumbnail Implementation (Issue #12 Fix)
The system now generates **square 300x300px thumbnails** to prevent blurry/stretched images in the gallery grid:
- **Problem**: Previously generated 300px width with proportional height (e.g., 300x200 for 3:2 photos), but CSS forced square display causing distortion
- **Solution**: Thumbnails now use `cover` fit mode to crop to exact 300x300px dimensions with center positioning
- **Configuration**: Settings stored in `app_settings` table with keys: `thumbnail_width`, `thumbnail_height`, `thumbnail_fit`, `thumbnail_quality`, `thumbnail_format`
- **Migration**: Run `040_add_thumbnail_settings.js` to add default square thumbnail settings
- **Regeneration Script**: Use `scripts/regenerate-square-thumbnails.js` to update existing thumbnails
### Thumbnail Settings API
- `GET /api/admin/thumbnails/settings` - Get current thumbnail configuration
- `PUT /api/admin/thumbnails/settings` - Update thumbnail settings (requires regeneration)
- `POST /api/admin/thumbnails/regenerate` - Regenerate all thumbnails with new settings
- `GET /api/admin/thumbnails/regenerate/status` - Check regeneration progress
## Success Metrics (from PRD)
- Time to generate gallery: <2 minutes
- Guest satisfaction: >90%
- System uptime: 99.9%
- Email delivery rate: >98%
- Successful archiving: 100%
## Documentation & Development Practices
### Documentation Guidelines:
- **NEVER create new documentation files for simple tasks**
- **ALWAYS update existing documentation (like this CLAUDE.md)**
- Only create new .md files when explicitly requested
- Avoid creating temporary scripts for one-off tasks
### Development Best Practices:
- Test all changes thoroughly in local environment first
- Use version control for all changes
- Keep commits atomic and well-described
- Review impact on all integrated services
- Consider backward compatibility
- Update tests when changing functionality
### Production Deployment Checklist:
- [ ] All tests passing locally
- [ ] Linting and type checks pass
- [ ] Database migrations tested with rollback plan
- [ ] Environment variables documented
- [ ] Backup strategy confirmed
- [ ] Monitoring alerts configured
- [ ] Rollback procedure documented
- [ ] Stakeholders notified of maintenance window
- always use docker deployment for testing
+238 -33
View File
@@ -4,27 +4,62 @@ This guide covers multiple deployment options for PicPeak, from simple local set
## 🎯 Quick Start - Simple Setup (Recommended for Beginners)
For the easiest installation without Docker or complex configurations, use our **simple setup script**:
For the easiest installation without Docker or complex configurations, use our **unified setup script**:
```bash
curl -fsSL https://raw.githubusercontent.com/yourusername/wedding-photo-sharing/main/scripts/simple-setup.sh -o setup.sh && \
chmod +x setup.sh && \
sudo ./setup.sh
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
This automated script handles everything including OS detection, dependencies, database setup, and service configuration. Perfect for:
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 who prefer avoiding Docker complexity
- Users new to server management
- Quick testing and evaluation
👉 **See [SIMPLE_SETUP_GUIDE.md](./SIMPLE_SETUP_GUIDE.md) for detailed instructions.**
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
---
## 🐳 Docker Compose Deployment
This section covers deploying PicPeak using Docker Compose with direct port exposure. For internet-facing deployments, you'll need to add a reverse proxy (nginx, Traefik, Caddy, etc.) for SSL/HTTPS.
### Option 1: Using Pre-built Images (Recommended)
PicPeak provides official Docker images via GitHub Container Registry for quick deployment without building:
```bash
# Clone repository for configuration files
git clone https://github.com/the-luap/picpeak.git
cd picpeak
# Copy and configure environment
cp .env.example .env
nano .env # Edit with your values
# Use pre-built images deployment
docker compose -f docker-compose.production.yml up -d
```
The production compose file uses:
- **Backend**: `ghcr.io/the-luap/picpeak/backend:latest`
- **Frontend**: `ghcr.io/the-luap/picpeak/frontend:latest`
Available tags:
- `latest` - Latest stable release
- `main` - Latest main branch build
- `develop` - Development branch (may be unstable)
- `v1.0.0` - Specific version tags
### Option 2: Building from Source
If you need to customize the application or the pre-built images aren't available, you can build locally:
## 📋 Table of Contents
@@ -36,6 +71,7 @@ This section covers deploying PicPeak using Docker Compose with direct port expo
- [Reverse Proxy Setup](#reverse-proxy-setup)
- [Maintenance](#maintenance)
- [Troubleshooting](#troubleshooting)
- [External Media Library](#external-media-library)
## Prerequisites
@@ -46,6 +82,75 @@ This section covers deploying PicPeak using Docker Compose with direct port expo
## 🚀 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
@@ -64,8 +169,9 @@ This section covers deploying PicPeak using Docker Compose with direct port expo
chmod -R 755 events data logs backup storage
```
4. **Deploy**
4. **Build and deploy**
```bash
docker compose build
docker compose up -d
```
@@ -95,6 +201,15 @@ openssl rand -base64 32 | tr -d '$'
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
### Public Landing Page
- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
### Backend Configuration (.env)
Update `.env` with:
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
@@ -102,8 +217,31 @@ Update `.env` with:
- `REDIS_PASSWORD` - Redis password
- `SMTP_*` - Email configuration
- **URL Configuration** (for backend CORS):
- `FRONTEND_URL` - Frontend URL (e.g., `http://localhost:3000` for Docker)
- `ADMIN_URL` - Admin URL (e.g., `http://localhost:3000` for Docker)
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
- Example (Docker): `http://localhost:3000`
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
- Example (Docker): `http://localhost:3000`
Notes:
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
- Always include the scheme (`http://` or `https://`).
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
#### Authentication Security
- Configure login attempt thresholds from **Admin → Settings → Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout.
#### External Database Example
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
```env
DB_HOST=db.example.com
DB_PORT=5432
DB_USER=picpeak
DB_PASSWORD=change_me
DB_NAME=picpeak_prod
```
Compose uses these values via `env_file: .env`. The backend service also defaults `DB_HOST=${DB_HOST:-postgres}` so if you dont set `DB_HOST` it will use the bundled `postgres` container.
### Frontend Configuration (frontend/.env)
Create `frontend/.env` from `frontend/.env.example`:
@@ -113,9 +251,10 @@ cp frontend/.env.example frontend/.env
Update `frontend/.env` with:
- `VITE_API_URL` - Backend API URL
- For Docker deployment: `http://localhost:3001/api`
- For non-Docker local dev: `http://localhost:3001`
- For production with reverse proxy: `/api`
- Docker (pre-built images) and production behind reverse proxy: `/api` (recommended; avoids CORS and matches the frontend Nginx proxy in the image)
- Local dev (Vite): `http://localhost:3001` or `/api` if proxying through a dev proxy
Note: When using pre-built frontend images, runtime container env does not change the already-built JS. Prefer the default `/api` and let the frontend Nginx proxy forward to the backend.
⚠️ **IMPORTANT PORT CONFIGURATION**:
- The frontend runs on port **3000** in Docker (exposed via nginx)
@@ -145,12 +284,29 @@ SMTP_PASS=your-sendgrid-api-key
## 📦 Deployment
### Build and Start Services
### Using Pre-built Images (Fastest)
```bash
# Build images
# Pull latest images from GitHub Container Registry
docker pull ghcr.io/the-luap/picpeak/backend:latest
docker pull ghcr.io/the-luap/picpeak/frontend:latest
# Start services using production compose file
docker compose -f docker-compose.production.yml up -d
# View running containers
docker compose ps
```
### Building from Source (For Customization)
```bash
# Build images locally
docker compose build
# Or build with no cache for clean build
docker compose build --no-cache
# Start all services
docker compose up -d
@@ -161,8 +317,8 @@ docker compose ps
### Access Points
By default, services are exposed on:
- Frontend: http://localhost:3000
- Backend/API: http://localhost:3001
- Frontend (UI + Admin): http://localhost:3000 (admin at `/admin`)
- Backend/API: http://localhost:3001 (API only; no UI routes)
- PostgreSQL: localhost:5432 (if needed)
- Redis: localhost:6379 (if needed)
@@ -233,7 +389,11 @@ After deployment, you must complete the first login process which includes manda
### Step 2: Access Admin Panel
1. Navigate to your admin panel URL (e.g., `http://your-domain.com:3001/admin` or `https://your-domain.com/admin`)
1. Navigate to your frontend domain and open the admin section:
- `http://your-domain.com/admin` (behind reverse proxy)
- `http://localhost:3000/admin` (Docker local)
The backend at `:3001` serves API only and does not serve the admin UI.
2. Login using:
- **Email**: `admin@example.com` (or your custom admin email)
- **Password**: The auto-generated password from the logs
@@ -263,7 +423,17 @@ Upon first login, the system will **automatically redirect** you to change your
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference.
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
```bash
# Native reinstall example
sudo ./picpeak-setup.sh --native --force-admin-password-reset
# Docker reinstall example
sudo ./picpeak-setup.sh --docker --force-admin-password-reset
```
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
#### Configuring Admin Email
@@ -307,7 +477,16 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend API
# Frontend (serves UI and /admin/*)
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend API and protected resources
location /api {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
@@ -315,21 +494,11 @@ server {
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Protected photos and uploads
location ~ ^/(photos|thumbnails|uploads) {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
# Admin routes
location /admin {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
@@ -446,14 +615,50 @@ The application includes a built-in backup service. Configure it in the admin pa
### Updates
#### Method 1: Using Pre-built Images (Recommended)
```bash
# Pull latest changes (for configuration updates)
git pull
# Pull latest images from GitHub Container Registry
docker compose -f docker-compose.production.yml pull
# Restart with new images
docker compose -f docker-compose.production.yml down
docker compose -f docker-compose.production.yml up -d
# Verify services are healthy
docker compose -f docker-compose.production.yml ps
```
#### Method 2: Building from Source
```bash
# Pull latest changes
git pull
# Rebuild and restart
docker compose down
docker compose build
docker compose build --no-cache
docker compose up -d
# Verify services are healthy
docker compose ps
```
#### Specific Version Updates
To use a specific version of the images:
```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
# Then pull and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
### Database Migrations
@@ -614,4 +819,4 @@ For issues and questions:
- Error messages
- Log output
- Environment details (without secrets)
- Steps to reproduce
- Steps to reproduce
+56 -3
View File
@@ -28,11 +28,13 @@ Unlike expensive SaaS solutions, PicPeak gives you:
### For Photographers
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
- 🔗 **External Media (Reference Mode)** - Browse and import from a readonly external folder library without copying originals
-**Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
- 🔐 **Password Protection** - Secure client galleries
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
- 🎨 **Custom Themes** - Match your brand perfectly
- 🌐 **Public Landing Page** - Publish a curated marketing page when guests visit your root URL
### For Clients
- 🖼️ **Beautiful Galleries** - Clean, modern interface
@@ -45,6 +47,7 @@ Unlike expensive SaaS solutions, PicPeak gives you:
### Technical Excellence
- 🐳 **Docker Ready** - Deploy in minutes
- 🔄 **Auto-Processing** - Automatic thumbnail generation
- 🗂️ **Reference Library Support** - Point PicPeak at `EXTERNAL_MEDIA_ROOT` to reference existing originals, index quickly, and generate thumbnails on demand
- 💾 **Smart Storage** - Automatic archiving of expired galleries
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
- 📈 **Scalable** - From small studios to large agencies
@@ -70,14 +73,37 @@ docker-compose up -d
# Access at http://localhost:3005
```
Note on Docker file permissions (PUID/PGID)
- When using bind mounts (e.g., `./storage`, `./data`, `./logs`, `./events`), ensure the container user can write to these host folders. The backend runs as a nonroot user by default.
- Set `PUID` and `PGID` in your `.env` to match your host users UID/GID (run `id -u` and `id -g` on the host). Compose maps the container user to these values.
- Example in `.env`:
- `PUID=1000`
- `PGID=1000`
- Without this, creating events, uploads, thumbnails, or logs can fail with “Permission denied”.
## 📖 Documentation
- 📘 [**Deployment Guide**](DEPLOYMENT_GUIDE.md) - Detailed installation instructions
- Includes the new [External Media Library](DEPLOYMENT_GUIDE.md#external-media-library) reference mode
- 📚 [**Admin API (OpenAPI)**](docs/picpeak-admin-api.openapi.yaml) - Machine-readable documentation for event automation endpoints
- 🛠️ [**Admin API Quickstart**](docs/admin-api-quickstart.md) - Step-by-step authentication and testing guide for the documented endpoints
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
- 📜 [**License**](LICENSE) - MIT License
- 🔒 [**Security**](SECURITY.md) - Security policies
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
## 🌐 Public Landing Page
Spotlight your studio with a customizable marketing page at `/`:
- Head to **Admin → CMS Pages** to enable the public landing page toggle.
- Edit the provided HTML template (rich sections, hero, testimonials) and optional CSS overrides.
- The preview renders in a sandboxed iframe so you can iterate safely before publishing.
- PicPeak sanitizes stored HTML and CSS server-side—scripts, iframes, and unsafe attributes are stripped automatically.
- Use **Reset to default** anytime to restore the bundled template.
- The backend caches the rendered landing page for 60 seconds by default; override with `PUBLIC_SITE_CACHE_TTL_MS` if you need a different TTL.
- When the landing page is disabled PicPeak continues to serve the admin SPA/login exactly as before.
## 🎯 Use Cases
Perfect for:
@@ -109,6 +135,31 @@ Perfect for:
- **Docker**: v20.10.0+
- **Docker Compose**: v2.0.0+
### Video Support Requirements
When enabling video uploads, consider these additional resources:
| Resource | Recommendation | Notes |
|----------|----------------|-------|
| **RAM** | 4GB+ recommended | FFmpeg processing requires more memory |
| **Storage** | Plan for 10-100x more | Videos are significantly larger than images |
| **CPU** | Additional cores help | Video thumbnail extraction is CPU-intensive |
| **Bandwidth** | Higher throughput | Video streaming requires more bandwidth |
**Technical Notes:**
- FFmpeg is bundled via npm (`@ffmpeg-installer/ffmpeg`) - no system installation required
- Maximum upload size: **10GB per video file**
- Chunked upload support for files >100MB (resumable uploads)
- Supported formats: MP4, WebM, MOV, AVI
- Video thumbnails are automatically generated from the first few seconds
**For Nginx/Reverse Proxy:**
If using Nginx, increase the client max body size:
```nginx
client_max_body_size 10G;
proxy_read_timeout 3600;
proxy_send_timeout 3600;
```
## 🤝 Contributing
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
@@ -192,11 +243,13 @@ These features are currently in beta testing and may have limited functionality
| Feature | Description | Priority | Status |
|---------|-------------|----------|---------|
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
| **External Media Library (Reference Mode)** | Use an external folder library as a readonly source with import and ondemand thumbnail generation | High | ✅ Implemented |
| **Gallery Templates** | Additional gallery layouts and themes (masonry, slideshow, story-style) for different event types | Medium | 🔄 Open |
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented (not tested) |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | ✅ Implemented |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | ✅ Implemented |
| **Multiple Administrators** | Support for multiple admin accounts with role-based permissions and activity tracking | Low | 📋 Planned |
| **Filtering & Export Options** | Add filters to show only rated, liked, or marked photos and export filtered selections for Capture One or Lightroom workflows | Low | 🔄 Open |
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
@@ -233,4 +286,4 @@ PicPeak is released under the [MIT License](LICENSE). Use it freely for personal
<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>
</p>
</p>
+50 -25
View File
@@ -8,9 +8,9 @@ This guide provides easy installation instructions for PicPeak on Linux servers
```bash
# Download and run the unified setup script
curl -fsSL https://raw.githubusercontent.com/yourusername/wedding-photo-sharing/main/scripts/setup.sh -o setup.sh && \
chmod +x setup.sh && \
sudo ./setup.sh
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
The script will automatically detect your environment and recommend the best installation method.
@@ -21,7 +21,7 @@ The script will automatically detect your environment and recommend the best ins
Best for: Most users, easy updates, isolated environment
```bash
sudo ./setup.sh --docker
sudo ./picpeak-setup.sh --docker
```
**Pros:**
@@ -38,7 +38,7 @@ sudo ./setup.sh --docker
Best for: Resource-constrained systems, Raspberry Pi, direct control
```bash
sudo ./setup.sh --native
sudo ./picpeak-setup.sh --native
```
**Pros:**
@@ -73,7 +73,7 @@ sudo ./setup.sh --native
### Interactive Mode (Default)
```bash
sudo ./setup.sh
sudo ./picpeak-setup.sh
```
The script will prompt you to choose:
@@ -87,7 +87,7 @@ The script will prompt you to choose:
#### Docker with full configuration:
```bash
sudo ./setup.sh --docker --unattended \
sudo ./picpeak-setup.sh --docker --unattended \
--domain photos.example.com \
--email admin@example.com \
--admin-password SecurePass123 \
@@ -100,7 +100,7 @@ sudo ./setup.sh --docker --unattended \
#### Native with minimal configuration:
```bash
sudo ./setup.sh --native --unattended \
sudo ./picpeak-setup.sh --native --unattended \
--email admin@example.com \
--admin-password SecurePass123
```
@@ -166,8 +166,10 @@ sudo ./setup.sh --native --unattended \
## 🌐 Access Methods
### Direct Access (Simplest)
- **Docker**: `http://your-server:3000` (frontend), `http://your-server:3001/admin` (admin)
- **Native**: `http://your-server:3001/admin` (admin panel)
- Docker: `http://your-server:3000` (frontend and admin at `/admin`)
- Backend/API: `http://your-server:3001` (API only; no UI routes)
For native installs, serve the built frontend (e.g., with nginx or Caddy) and access the admin at `/admin` on the frontend domain.
### With Domain & HTTPS
If configured during setup:
@@ -175,9 +177,23 @@ If configured during setup:
- `https://your-domain.com/admin` - Admin panel
### Behind Existing Proxy
Add to your Nginx/Apache configuration:
Add to your Nginx/Apache configuration (split frontend vs backend):
```nginx
# Frontend (UI + /admin/*)
location / {
proxy_pass http://localhost:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend API and protected resources
location /api {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
@@ -189,6 +205,14 @@ location / {
proxy_set_header X-Forwarded-Proto $scheme;
client_max_body_size 100M;
}
location ~ ^/(photos|thumbnails|uploads) {
proxy_pass http://localhost:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
```
## 📁 Managing Galleries
@@ -268,7 +292,8 @@ sudo systemctl stop picpeak-backend picpeak-workers
sudo systemctl restart picpeak-backend picpeak-workers
# Update PicPeak
sudo ./setup.sh --update
# (reruns migrations to pick up schema fixes for native installs)
sudo ./picpeak-setup.sh --update
```
## ⚙️ Configuration
@@ -281,9 +306,9 @@ docker compose restart
```
### Native Configuration
Edit `/opt/picpeak/backend/.env`:
Edit `/opt/picpeak/app/backend/.env`:
```bash
sudo nano /opt/picpeak/backend/.env
sudo nano /opt/picpeak/app/backend/.env
sudo systemctl restart picpeak-backend
```
@@ -345,7 +370,7 @@ tar -czf photos-backup.tar.gz storage/events/
#### Native:
```bash
# Database backup
sudo cp /opt/picpeak/backend/database.sqlite /backup/database-$(date +%Y%m%d).sqlite
sudo cp /opt/picpeak/app/backend/data/photo_sharing.db /backup/database-$(date +%Y%m%d).sqlite
# Photos backup
sudo tar -czf /backup/photos-$(date +%Y%m%d).tar.gz /opt/picpeak/events/
@@ -360,14 +385,14 @@ docker compose pull
docker compose up -d
# Native
sudo ./setup.sh --update
sudo ./picpeak-setup.sh --update
```
### Uninstall
```bash
# Will prompt for confirmation and data removal options
sudo ./setup.sh --uninstall
sudo ./picpeak-setup.sh --uninstall
```
## 🐛 Troubleshooting
@@ -422,7 +447,7 @@ ls -la ~/picpeak/storage/events/
docker exec picpeak-backend node scripts/reset-admin-password.js
# Native
cd /opt/picpeak/backend
cd /opt/picpeak/app/backend
sudo -u picpeak node scripts/reset-admin-password.js
```
@@ -434,11 +459,11 @@ sudo -u picpeak node scripts/reset-admin-password.js
- Installation: `/tmp/picpeak-setup-*.log`
2. **Documentation:**
- [Full Documentation](https://github.com/yourusername/wedding-photo-sharing)
- [Full Documentation](https://github.com/the-luap/picpeak)
- [Deployment Guide](./DEPLOYMENT_GUIDE.md)
3. **Support:**
- [GitHub Issues](https://github.com/yourusername/wedding-photo-sharing/issues)
- [GitHub Issues](https://github.com/the-luap/picpeak/issues)
- Include: Error messages, system info (`uname -a`), installation method
## 🔒 Security Best Practices
@@ -474,7 +499,7 @@ services:
### Native Optimization
```bash
# Increase Node.js memory
echo "NODE_OPTIONS=--max-old-space-size=2048" >> /opt/picpeak/backend/.env
echo "NODE_OPTIONS=--max-old-space-size=2048" >> /opt/picpeak/app/backend/.env
sudo systemctl restart picpeak-backend
```
@@ -483,13 +508,13 @@ sudo systemctl restart picpeak-backend
### Home/Office Network
```bash
# Simple local setup without domain
sudo ./setup.sh --native --email admin@local.com
sudo ./picpeak-setup.sh --native --email admin@local.com
```
### Public Website with HTTPS
```bash
# Full production setup
sudo ./setup.sh --docker \
sudo ./picpeak-setup.sh --docker \
--domain photos.company.com \
--email admin@company.com \
--enable-ssl
@@ -498,7 +523,7 @@ sudo ./setup.sh --docker \
### Raspberry Pi Setup
```bash
# Optimized for ARM devices
sudo ./setup.sh --native \
sudo ./picpeak-setup.sh --native \
--port 8080 \
--email pi@local.com
```
@@ -516,4 +541,4 @@ sudo ./setup.sh --native \
---
**PicPeak Setup v1.0** | [Documentation](https://github.com/yourusername/wedding-photo-sharing) | [Support](https://github.com/yourusername/wedding-photo-sharing/issues)
**PicPeak Setup v1.0** | [Documentation](https://github.com/the-luap/picpeak) | [Support](https://github.com/the-luap/picpeak/issues)
+13 -4
View File
@@ -1,4 +1,4 @@
FROM node:18-alpine AS builder
FROM node:20-alpine AS builder
# Add build arguments
ARG CACHEBUST=1
@@ -11,22 +11,31 @@ 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
RUN npm install -g npm@latest
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
# Install dependencies (--omit=dev replaces deprecated --only=production)
RUN npm ci --omit=dev
# Copy application files
COPY . .
# Production stage
FROM node:18-alpine
FROM node:20-alpine
WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
RUN apk upgrade --no-cache
# Upgrade npm to fix glob CVE-2025-64756 vulnerability
RUN npm install -g npm@latest
# Install dumb-init for proper signal handling and postgresql-client for database checks
RUN apk add --no-cache dumb-init postgresql-client
+4 -1
View File
@@ -1,7 +1,10 @@
FROM node:18-alpine
FROM node:20-alpine
WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
RUN apk upgrade --no-cache
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
@@ -0,0 +1,184 @@
const fs = require('fs');
const fsPromises = fs.promises;
const os = require('os');
const path = require('path');
const express = require('express');
const request = require('supertest');
describe('Admin settings logo upload flow', () => {
let tmpDir;
let router;
let app;
let settingsStore;
const resetModules = () => {
jest.resetModules();
jest.clearAllMocks();
};
beforeEach(async () => {
resetModules();
tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-logo-'));
process.env.STORAGE_PATH = tmpDir;
settingsStore = new Map();
const buildQuery = (table) => {
const filters = [];
const applyFilters = (rows) => {
if (filters.length === 0) {
return rows;
}
return rows.filter((row) =>
filters.every(({ column, value }) => row[column] === value)
);
};
const makeRow = (row) => ({ ...row });
return {
where(column, value) {
filters.push({ column, value });
return this;
},
first() {
if (table === 'app_settings') {
const rows = applyFilters(Array.from(settingsStore.values()).map(makeRow));
return Promise.resolve(rows[0]);
}
return Promise.resolve(undefined);
},
select() {
return Promise.resolve([]);
},
sum() {
return Promise.resolve({ total: 0 });
},
join() {
return this;
},
groupBy() {
return this;
},
orderBy() {
return this;
},
limit() {
return this;
},
insert(payload) {
const rows = Array.isArray(payload) ? payload : [payload];
const upsert = (row, overrides = {}) => {
if (table === 'app_settings') {
const key = row.setting_key;
const existing = settingsStore.get(key) || {};
settingsStore.set(key, { ...existing, ...row, ...overrides });
}
return Promise.resolve();
};
return {
onConflict() {
return {
merge(overrides) {
return Promise.all(rows.map((row) => upsert(row, overrides))).then(() => undefined);
}
};
}
};
}
};
};
const dbMock = jest.fn((table) => buildQuery(table));
dbMock.raw = jest.fn();
dbMock.transaction = async (handler) => handler({
commit: async () => {},
rollback: async () => {}
});
jest.doMock('../src/database/db', () => ({
db: dbMock,
logActivity: jest.fn()
}));
jest.doMock('../src/middleware/auth', () => ({
adminAuth: (req, res, next) => {
req.admin = { id: 1, username: 'tester' };
next();
}
}));
jest.doMock('../src/services/publicSiteService', () => ({
clearPublicSiteCache: jest.fn(),
getDefaultPublicSitePayload: jest.fn(),
getRawPublicSiteSettings: jest.fn().mockResolvedValue({})
}));
jest.doMock('../src/services/rateLimitService', () => ({
clearSettingsCache: jest.fn()
}));
jest.doMock('../src/middleware/maintenance', () => ({
maintenanceMiddleware: (req, res, next) => next(),
clearMaintenanceCache: jest.fn()
}));
router = require('../src/routes/adminSettings');
app = express();
app.use(express.json());
app.use('/api/admin/settings', router);
});
afterEach(async () => {
resetModules();
if (tmpDir) {
await fsPromises.rm(tmpDir, { recursive: true, force: true });
tmpDir = null;
}
delete process.env.STORAGE_PATH;
});
it('stores logo uploads under STORAGE_PATH and deletes on branding reset', async () => {
const fileBuffer = Buffer.from('fake image data');
const uploadResponse = await request(app)
.post('/api/admin/settings/logo')
.attach('logo', fileBuffer, 'logo.png');
expect(uploadResponse.status).toBe(200);
expect(uploadResponse.body).toHaveProperty('logoUrl');
const logoUrl = uploadResponse.body.logoUrl;
expect(logoUrl.startsWith('/uploads/logos/')).toBe(true);
const storedPath = path.join(tmpDir, logoUrl.replace('/uploads/', 'uploads/'));
await expect(fsPromises.access(storedPath)).resolves.toBeUndefined();
await request(app)
.put('/api/admin/settings/branding')
.send({
company_name: 'Test Co',
company_tagline: 'Tagline',
support_email: 'test@example.com',
footer_text: 'Footer',
watermark_enabled: false,
watermark_position: 'bottom-right',
watermark_opacity: 0.5,
watermark_size: 'medium',
favicon_url: null,
logo_url: '',
watermark_logo_url: null,
logo_size: 'medium',
logo_max_height: 120,
logo_position: 'left',
logo_display_header: true,
logo_display_hero: false,
logo_display_mode: 'default'
})
.expect(200);
await expect(fsPromises.access(storedPath)).rejects.toThrow();
});
});
@@ -0,0 +1,207 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('Admin photos in reference mode', () => {
let tmpDir;
let storagePath;
let db;
let app;
let categoryId;
const resetModules = () => {
jest.resetModules();
jest.clearAllMocks();
};
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-admin-photos-'));
storagePath = path.join(tmpDir, 'storage');
await fs.promises.mkdir(storagePath, { recursive: true });
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'photo_sharing_test.db');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
try {
await fs.promises.unlink(process.env.TEST_DATABASE_PATH);
} catch (_) {
/* ignore */
}
process.env.STORAGE_PATH = storagePath;
resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => {
req.admin = { id: 1, username: 'tester' };
next();
}
}));
jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/mock-thumb.jpg'),
ensureThumbnail: jest.fn()
}));
jest.doMock('../../src/middleware/uploadValidation', () => ({
validateUploadedFiles: (_req, _res, next) => next()
}));
jest.doMock('../../src/utils/fileSecurityUtils', () => {
const actual = jest.requireActual('../../src/utils/fileSecurityUtils');
return {
...actual,
validateFileType: () => true,
createFileUploadValidator: () => (_req, _res, next) => next()
};
});
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
}));
const dbModule = require('../../src/database/db');
db = dbModule.db;
await db.schema.dropTableIfExists('photo_feedback');
await db.schema.dropTableIfExists('photos');
await db.schema.dropTableIfExists('photo_categories');
await db.schema.dropTableIfExists('events');
await db.schema.createTable('events', (table) => {
table.increments('id').primary();
table.string('slug').notNullable();
table.string('event_name').notNullable();
table.string('source_mode').notNullable();
table.string('external_path');
});
await db.schema.createTable('photo_categories', (table) => {
table.increments('id').primary();
table.string('name').notNullable();
table.string('slug').notNullable();
table.boolean('is_global').defaultTo(true);
table.integer('event_id');
});
await db.schema.createTable('photos', (table) => {
table.increments('id').primary();
table.integer('event_id').notNullable();
table.string('filename').notNullable();
table.string('path').notNullable();
table.string('thumbnail_path');
table.string('type').notNullable();
table.integer('size_bytes');
table.integer('category_id');
table.string('source_origin');
table.string('external_relpath');
table.datetime('uploaded_at').defaultTo(db.fn.now());
table.float('average_rating').defaultTo(0);
table.integer('like_count').defaultTo(0);
table.integer('favorite_count').defaultTo(0);
});
await db.schema.createTable('photo_feedback', (table) => {
table.increments('id');
table.integer('photo_id');
table.string('feedback_type');
table.boolean('is_approved');
table.boolean('is_hidden');
});
await db('events').insert({
id: 1,
slug: 'test-event',
event_name: 'Test Event',
source_mode: 'reference',
external_path: 'external/library'
});
const insertedCategory = await db('photo_categories').insert({
name: 'Highlights',
slug: 'highlights',
is_global: true
});
categoryId = Array.isArray(insertedCategory) ? insertedCategory[0] : insertedCategory;
const router = require('../../src/routes/adminPhotos');
app = express();
app.use(express.json());
app.use('/api/admin/events', router);
});
afterAll(async () => {
if (db) {
await db.destroy();
}
resetModules();
delete process.env.TEST_DATABASE_PATH;
delete process.env.STORAGE_PATH;
if (tmpDir) {
await fs.promises.rm(tmpDir, { recursive: true, force: true });
}
});
it('stores managed uploads with category information and managed origin', async () => {
const uploadResponse = await request(app)
.post(`/api/admin/events/1/upload`)
.field('category_id', String(categoryId))
.attach('photos', Buffer.from('fake image data'), 'photo.jpg');
expect(uploadResponse.status).toBe(200);
expect(uploadResponse.body).toHaveProperty('photos');
expect(Array.isArray(uploadResponse.body.photos)).toBe(true);
const photo = await db('photos').first();
expect(photo).toBeTruthy();
expect(photo.category_id).toBe(categoryId);
expect(photo.source_origin).toBe('managed');
expect(photo.external_relpath).toBeNull();
});
it('returns numeric category metadata when listing photos', async () => {
await db('photos').insert({
event_id: 1,
filename: 'external.jpg',
path: 'test-event/external.jpg',
thumbnail_path: null,
type: 'individual',
size_bytes: 123,
source_origin: 'external',
external_relpath: 'individual/external.jpg'
});
const response = await request(app)
.get(`/api/admin/events/1/photos`)
.expect(200);
expect(Array.isArray(response.body.photos)).toBe(true);
const managedPhoto = response.body.photos.find((p) => p.category_id === categoryId);
expect(managedPhoto).toBeTruthy();
expect(managedPhoto.category_name).toBe('Highlights');
const filtered = await request(app)
.get(`/api/admin/events/1/photos`)
.query({ category_id: String(categoryId) })
.expect(200);
expect(filtered.body.photos.every((p) => p.category_id === categoryId)).toBe(true);
});
it('normalizes category updates', async () => {
const photo = await db('photos').first();
await request(app)
.patch(`/api/admin/events/1/photos/${photo.id}`)
.send({ category_id: '0' })
.expect(200);
const updated = await db('photos').where({ id: photo.id }).first();
expect(updated.category_id).toBeNull();
});
});
@@ -1,4 +1,4 @@
const { describe, it, expect, jest, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
const { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
const path = require('path');
const fs = require('fs').promises;
@@ -503,4 +503,4 @@ describe('S3 Backup Integration Tests', () => {
console.error('Failed to cleanup S3 objects:', error);
}
}
});
});
@@ -1,4 +1,4 @@
const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals');
const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
const mockFs = require('mock-fs');
const path = require('path');
const crypto = require('crypto');
@@ -748,4 +748,4 @@ describe('Enhanced Backup Service Tests', () => {
);
});
});
});
});
@@ -0,0 +1,85 @@
const path = require('path');
const mockPath = path;
jest.mock('../../src/services/externalMediaService', () => ({
resolveExternalPath: jest.fn((event, relPath) => mockPath.join('/mock/external', event.external_path || '', relPath || '')),
}));
const { resolveExternalPath } = require('../../src/services/externalMediaService');
const { resolvePhotoFilePath } = require('../../src/services/photoResolver');
describe('resolvePhotoFilePath', () => {
const backendRoot = path.resolve(__dirname, '../../');
const originalStoragePath = process.env.STORAGE_PATH;
beforeEach(() => {
process.env.STORAGE_PATH = path.join(backendRoot, 'storage');
});
afterEach(() => {
jest.clearAllMocks();
});
afterAll(() => {
if (typeof originalStoragePath === 'string') {
process.env.STORAGE_PATH = originalStoragePath;
} else {
delete process.env.STORAGE_PATH;
}
});
it('returns absolute path for managed photos with legacy slug paths', () => {
const event = { slug: 'wedding-party', source_mode: 'managed' };
const photo = { path: 'wedding-party/hero.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
});
it('normalizes prefixed managed paths without duplicating segments', () => {
const event = { slug: 'wedding-party', source_mode: 'managed' };
const photo = { path: 'events/active/wedding-party/hero.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'wedding-party', 'hero.jpg'));
});
it('delegates external photos to external media resolver', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
const photo = { source_origin: 'external', external_relpath: 'individual/look-01.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'individual/look-01.jpg');
expect(result).toBe(path.join('/mock/external', 'picsum-demo', 'individual', 'look-01.jpg'));
});
it('deduplicates folder names when event external path already ends with segment', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo/individual' };
const photo = { source_origin: 'external', external_relpath: 'individual/look-02.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(resolveExternalPath).toHaveBeenCalledWith(event, 'look-02.jpg');
expect(result).toBe(path.join('/mock/external', 'picsum-demo/individual', 'look-02.jpg'));
});
it('falls back to managed storage when external metadata is missing', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
const photo = { path: 'fashion-show/new-upload.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(resolveExternalPath).not.toHaveBeenCalled();
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'fashion-show', 'new-upload.jpg'));
});
it('throws when external photo is missing relative path data', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
const photo = { source_origin: 'external' };
expect(() => resolvePhotoFilePath(event, photo)).toThrow('Missing external_relpath for external photo');
});
});
Binary file not shown.
+4 -4
View File
@@ -1831,8 +1831,8 @@
}
},
"nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
"overridden": false
},
"nodemon": {
@@ -2086,8 +2086,8 @@
"version": "4.0.1"
},
"tar-fs": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"overridden": false
},
"tunnel-agent": {
+6
View File
@@ -1,4 +1,10 @@
beforeAll(() => {
process.env.NODE_ENV = 'test';
process.env.JWT_SECRET = 'test-secret';
if (!process.env.SKIP_S3_TESTS) {
process.env.SKIP_S3_TESTS = 'true';
}
if (!process.env.STORAGE_PATH) {
process.env.STORAGE_PATH = '/storage';
}
});
+102 -25
View File
@@ -3,6 +3,54 @@ require('dotenv').config();
const path = require('path');
// Database configuration for different environments
const resolveSqliteFilename = (filenameEnv) => {
const fallback = path.join(__dirname, './data/photo_sharing.db');
if (!filenameEnv) {
return fallback;
}
const trimmed = String(filenameEnv).trim();
if (!trimmed) {
return fallback;
}
let resolved;
if (path.isAbsolute(trimmed)) {
resolved = trimmed;
} else if (trimmed.startsWith('./') || trimmed.startsWith('../')) {
resolved = path.resolve(__dirname, trimmed);
} else {
resolved = path.join(__dirname, trimmed);
}
const normalized = path.normalize(resolved);
const baseSuffix = path.relative(path.parse(__dirname).root, path.normalize(__dirname));
const duplicatePattern = `${path.sep}${baseSuffix}${path.sep}${baseSuffix}`;
if (normalized.includes(duplicatePattern)) {
return normalized.replace(duplicatePattern, `${path.sep}${baseSuffix}`);
}
return normalized;
};
const sqliteConnection = (filenameEnv) => ({
filename: resolveSqliteFilename(filenameEnv)
});
const baseSqliteConfig = {
client: 'sqlite3',
connection: sqliteConnection(),
useNullAsDefault: true,
migrations: {
directory: './migrations'
},
seeds: {
directory: './seeds'
}
};
const config = {
development: {
client: process.env.DATABASE_CLIENT || 'sqlite3',
@@ -13,7 +61,7 @@ const config = {
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'photo_sharing'
} : {
filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db')
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
},
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
migrations: {
@@ -24,36 +72,65 @@ const config = {
}
},
test: (() => {
const client = process.env.DATABASE_CLIENT || 'sqlite3';
const isPostgres = client === 'pg';
return {
...baseSqliteConfig,
client,
useNullAsDefault: !isPostgres,
connection: isPostgres
? {
host: process.env.DB_HOST || 'localhost',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'postgres',
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'photo_sharing_test'
}
: sqliteConnection(process.env.TEST_DATABASE_PATH || './data/photo_sharing_test.db')
};
})(),
production: {
client: process.env.DATABASE_CLIENT || 'pg',
connection: {
host: process.env.DB_HOST || 'db',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'picpeak',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'picpeak',
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
// Connection stability settings
connectionTimeoutMillis: 30000,
idleTimeoutMillis: 30000,
keepAlive: true,
keepAliveInitialDelayMillis: 0
},
pool: {
min: 5,
max: 25,
acquireTimeoutMillis: 60000,
createTimeoutMillis: 60000,
idleTimeoutMillis: 30000,
reapIntervalMillis: 1000,
createRetryIntervalMillis: 200,
propagateCreateError: false
},
// Support both Postgres and SQLite in production based on DATABASE_CLIENT
connection: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
? {
host: process.env.DB_HOST || 'db',
port: process.env.DB_PORT || 5432,
user: process.env.DB_USER || 'picpeak',
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME || 'picpeak',
ssl: process.env.DB_SSL === 'true' ? { rejectUnauthorized: false } : false,
// Connection stability settings
connectionTimeoutMillis: 30000,
idleTimeoutMillis: 30000,
keepAlive: true,
keepAliveInitialDelayMillis: 0
}
: {
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
},
useNullAsDefault: (process.env.DATABASE_CLIENT || 'pg') !== 'pg',
pool: (process.env.DATABASE_CLIENT || 'pg') === 'pg'
? {
min: 5,
max: 25,
acquireTimeoutMillis: 60000,
createTimeoutMillis: 60000,
idleTimeoutMillis: 30000,
reapIntervalMillis: 1000,
createRetryIntervalMillis: 200,
propagateCreateError: false
}
: undefined,
migrations: {
directory: './migrations'
},
acquireConnectionTimeout: 60000
}
};
const env = process.env.NODE_ENV || 'development';
module.exports = config[process.env.NODE_ENV || 'development'];
module.exports = config[env] || config.development;
@@ -0,0 +1,89 @@
exports.up = async function(knex) {
console.log('Running migration: 041_add_logo_customization_settings');
// Add default logo customization settings
const logoSettings = [
{
setting_key: 'branding_logo_size',
setting_value: JSON.stringify('medium'),
setting_type: 'branding',
description: 'Logo size: small, medium, large, xlarge, or custom',
created_at: new Date(),
updated_at: new Date()
},
{
setting_key: 'branding_logo_max_height',
setting_value: JSON.stringify(48),
setting_type: 'branding',
description: 'Maximum logo height in pixels (used when size is custom)',
created_at: new Date(),
updated_at: new Date()
},
{
setting_key: 'branding_logo_position',
setting_value: JSON.stringify('left'),
setting_type: 'branding',
description: 'Logo position in header: left, center, right',
created_at: new Date(),
updated_at: new Date()
},
{
setting_key: 'branding_logo_display_header',
setting_value: JSON.stringify(true),
setting_type: 'branding',
description: 'Show logo in gallery header',
created_at: new Date(),
updated_at: new Date()
},
{
setting_key: 'branding_logo_display_hero',
setting_value: JSON.stringify(true),
setting_type: 'branding',
description: 'Show logo in hero section (for non-grid layouts)',
created_at: new Date(),
updated_at: new Date()
},
{
setting_key: 'branding_logo_display_mode',
setting_value: JSON.stringify('logo_and_text'),
setting_type: 'branding',
description: 'Display mode: logo_only, text_only, logo_and_text',
created_at: new Date(),
updated_at: new Date()
}
];
// Insert settings that don't already exist
for (const setting of logoSettings) {
const exists = await knex('app_settings')
.where('setting_key', setting.setting_key)
.first();
if (!exists) {
await knex('app_settings').insert(setting);
console.log(`Added setting: ${setting.setting_key}`);
} else {
console.log(`Setting already exists: ${setting.setting_key}`);
}
}
console.log('Migration 041_add_logo_customization_settings completed');
};
exports.down = async function(knex) {
console.log('Rolling back migration: 041_add_logo_customization_settings');
// Remove the logo customization settings
await knex('app_settings')
.whereIn('setting_key', [
'branding_logo_size',
'branding_logo_max_height',
'branding_logo_position',
'branding_logo_display_header',
'branding_logo_display_hero',
'branding_logo_display_mode'
])
.del();
console.log('Rollback of 041_add_logo_customization_settings completed');
};
@@ -0,0 +1,54 @@
/**
* Migration 041: Add external media reference support
* - events.source_mode: 'managed' | 'reference'
* - events.external_path: relative path under external media root
* - photos.source_origin: 'managed' | 'external'
* - photos.external_relpath: relative path within event.external_path
*/
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function(knex) {
console.log('Running migration: 041_add_external_media');
// events.source_mode (default 'managed')
await addColumnIfNotExists(knex, 'events', 'source_mode', (table) => {
table.string('source_mode').notNullable().defaultTo('managed');
});
// events.external_path (nullable)
await addColumnIfNotExists(knex, 'events', 'external_path', (table) => {
table.text('external_path');
});
// photos.source_origin (default 'managed')
await addColumnIfNotExists(knex, 'photos', 'source_origin', (table) => {
table.string('source_origin').notNullable().defaultTo('managed');
});
// photos.external_relpath (nullable)
await addColumnIfNotExists(knex, 'photos', 'external_relpath', (table) => {
table.text('external_relpath');
});
// Helpful index for queries
try {
if (knex.client.config.client === 'pg') {
await knex.raw("CREATE INDEX IF NOT EXISTS photos_event_source_idx ON photos (event_id, source_origin)");
} else {
await knex.schema.alterTable('photos', (table) => {
table.index(['event_id', 'source_origin'], 'photos_event_source_idx');
});
}
} catch (e) {
console.log('Index creation skipped or failed (may already exist):', e.message);
}
console.log('Migration 041_add_external_media completed');
};
exports.down = async function(knex) {
console.log('Rollback: 041_add_external_media');
// Keep columns (safe rollback not removing data). Intentionally no-op.
};
@@ -0,0 +1,51 @@
const logger = require('../../src/utils/logger');
async function ensureColumn(knex, tableName, columnName, alterFn) {
const exists = await knex.schema.hasColumn(tableName, columnName);
if (!exists) {
logger.info(`Adding column ${tableName}.${columnName}`);
await knex.schema.table(tableName, alterFn);
}
}
exports.up = async function(knex) {
await ensureColumn(knex, 'events', 'host_name', (table) => {
table.string('host_name');
});
await ensureColumn(knex, 'events', 'allow_user_uploads', (table) => {
table.boolean('allow_user_uploads').defaultTo(false);
});
await ensureColumn(knex, 'events', 'upload_category_id', (table) => {
table.integer('upload_category_id');
});
await ensureColumn(knex, 'events', 'allow_downloads', (table) => {
table.boolean('allow_downloads').defaultTo(true);
});
await ensureColumn(knex, 'events', 'disable_right_click', (table) => {
table.boolean('disable_right_click').defaultTo(false);
});
await ensureColumn(knex, 'events', 'watermark_downloads', (table) => {
table.boolean('watermark_downloads').defaultTo(false);
});
await ensureColumn(knex, 'events', 'watermark_text', (table) => {
table.text('watermark_text');
});
await ensureColumn(knex, 'events', 'hero_photo_id', (table) => {
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
});
await ensureColumn(knex, 'photos', 'uploaded_by', (table) => {
table.string('uploaded_by').defaultTo('admin');
});
};
exports.down = async function() {
// Non destructive migration; no rollback
};
@@ -0,0 +1,46 @@
const {
DEFAULT_PUBLIC_SITE_HTML,
} = require('../../src/constants/publicSiteDefaults');
exports.up = async function(knex) {
const defaults = [
{
setting_key: 'general_public_site_enabled',
setting_value: JSON.stringify(false),
setting_type: 'general'
},
{
setting_key: 'general_public_site_html',
setting_value: JSON.stringify(DEFAULT_PUBLIC_SITE_HTML.trim()),
setting_type: 'general'
},
{
setting_key: 'general_public_site_custom_css',
setting_value: JSON.stringify(''),
setting_type: 'general'
}
];
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', [
'general_public_site_enabled',
'general_public_site_html',
'general_public_site_custom_css'
])
.del();
};
@@ -0,0 +1,18 @@
exports.up = async function (knex) {
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
if (!hasColumn) {
await knex.schema.table('events', (table) => {
table.boolean('require_password').notNullable().defaultTo(true);
});
await knex('events').update({ require_password: true });
}
};
exports.down = async function (knex) {
const hasColumn = await knex.schema.hasColumn('events', 'require_password');
if (hasColumn) {
await knex.schema.table('events', (table) => {
table.dropColumn('require_password');
});
}
};
@@ -0,0 +1,48 @@
const { DEFAULT_MAX_FILES_PER_UPLOAD, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../../src/services/uploadSettings');
exports.up = async function up(knex) {
const settingKey = 'general_max_files_per_upload';
const existing = await knex('app_settings')
.where({ setting_key: settingKey })
.first();
if (existing) {
// Normalize existing value into allowed bounds
let parsedValue;
try {
parsedValue = existing.setting_value != null ? JSON.parse(existing.setting_value) : null;
} catch {
parsedValue = existing.setting_value;
}
const numeric = Number(parsedValue);
let normalized = DEFAULT_MAX_FILES_PER_UPLOAD;
if (Number.isFinite(numeric) && numeric >= 1) {
normalized = Math.min(MAX_ALLOWED_FILES_PER_UPLOAD, Math.floor(numeric));
}
if (normalized !== numeric) {
await knex('app_settings')
.where({ setting_key: settingKey })
.update({
setting_value: JSON.stringify(normalized),
updated_at: new Date()
});
}
return;
}
await knex('app_settings').insert({
setting_key: settingKey,
setting_value: JSON.stringify(DEFAULT_MAX_FILES_PER_UPLOAD),
setting_type: 'general',
updated_at: new Date()
});
};
exports.down = async function down(knex) {
await knex('app_settings')
.where({ setting_key: 'general_max_files_per_upload' })
.del();
};
@@ -0,0 +1,44 @@
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function up(knex) {
await addColumnIfNotExists(knex, 'events', 'customer_name', (table) => {
table.string('customer_name');
});
await addColumnIfNotExists(knex, 'events', 'customer_email', (table) => {
table.string('customer_email');
});
// Backfill new columns from legacy host_* fields
const client = knex?.client?.config?.client;
if (client === 'pg') {
await knex.raw(`
UPDATE events
SET customer_name = COALESCE(customer_name, host_name),
customer_email = COALESCE(customer_email, host_email)
`);
} else {
// SQLite fallback
await knex('events').update({
customer_name: knex.raw('COALESCE(customer_name, host_name)'),
customer_email: knex.raw('COALESCE(customer_email, host_email)')
});
}
};
exports.down = async function down(knex) {
const hasCustomerName = await knex.schema.hasColumn('events', 'customer_name');
if (hasCustomerName) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('customer_name');
});
}
const hasCustomerEmail = await knex.schema.hasColumn('events', 'customer_email');
if (hasCustomerEmail) {
await knex.schema.alterTable('events', (table) => {
table.dropColumn('customer_email');
});
}
};
@@ -0,0 +1,18 @@
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function up(knex) {
// Add tls_reject_unauthorized column to email_configs table
// Default is true (validate certificates), false means ignore SSL/TLS certificate errors
await addColumnIfNotExists(knex, 'email_configs', 'tls_reject_unauthorized', (table) => {
table.boolean('tls_reject_unauthorized').defaultTo(true);
});
};
exports.down = async function down(knex) {
const hasColumn = await knex.schema.hasColumn('email_configs', 'tls_reject_unauthorized');
if (hasColumn) {
await knex.schema.alterTable('email_configs', (table) => {
table.dropColumn('tls_reject_unauthorized');
});
}
};
@@ -0,0 +1,109 @@
const { addColumnIfNotExists } = require('../helpers');
/**
* Migration: Add video support to photos table
* - Adds columns for video metadata (media_type, duration, codecs, dimensions)
* - Updates existing photos to have media_type 'image'
*/
exports.up = async function(knex) {
console.log('Running migration: 042_add_video_support');
// Add media_type column (image or video)
await addColumnIfNotExists(knex, 'photos', 'media_type', (table) => {
table.string('media_type').defaultTo('image');
});
// Add mime_type column if not exists
await addColumnIfNotExists(knex, 'photos', 'mime_type', (table) => {
table.string('mime_type');
});
// Add duration column (for videos, in seconds)
await addColumnIfNotExists(knex, 'photos', 'duration', (table) => {
table.integer('duration');
});
// Add video codec information
await addColumnIfNotExists(knex, 'photos', 'video_codec', (table) => {
table.string('video_codec');
});
// Add audio codec information
await addColumnIfNotExists(knex, 'photos', 'audio_codec', (table) => {
table.string('audio_codec');
});
// Add width dimension
await addColumnIfNotExists(knex, 'photos', 'width', (table) => {
table.integer('width');
});
// Add height dimension
await addColumnIfNotExists(knex, 'photos', 'height', (table) => {
table.integer('height');
});
// Update existing photos to have media_type 'image' if not set
const hasMediaType = await knex.schema.hasColumn('photos', 'media_type');
if (hasMediaType) {
await knex('photos')
.whereNull('media_type')
.orWhere('media_type', '')
.update({ media_type: 'image' });
console.log('Updated existing photos to have media_type "image"');
}
console.log('Migration 042_add_video_support completed');
};
exports.down = async function(knex) {
console.log('Rolling back migration: 042_add_video_support');
// Remove video support columns
const hasMediaType = await knex.schema.hasColumn('photos', 'media_type');
if (hasMediaType) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('media_type');
});
}
const hasDuration = await knex.schema.hasColumn('photos', 'duration');
if (hasDuration) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('duration');
});
}
const hasVideoCodec = await knex.schema.hasColumn('photos', 'video_codec');
if (hasVideoCodec) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('video_codec');
});
}
const hasAudioCodec = await knex.schema.hasColumn('photos', 'audio_codec');
if (hasAudioCodec) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('audio_codec');
});
}
const hasWidth = await knex.schema.hasColumn('photos', 'width');
if (hasWidth) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('width');
});
}
const hasHeight = await knex.schema.hasColumn('photos', 'height');
if (hasHeight) {
await knex.schema.alterTable('photos', (table) => {
table.dropColumn('height');
});
}
// Note: We don't drop mime_type as it may be used by images as well
console.log('Rollback of 042_add_video_support completed');
};
@@ -1,23 +1,56 @@
exports.up = async function(knex) {
// Add user upload settings to events table
await knex.schema.alterTable('events', function(table) {
table.boolean('allow_user_uploads').defaultTo(false);
table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL');
});
// Add user upload settings to events table (check if columns exist first)
const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads');
if (!hasAllowUserUploads) {
console.log('Adding allow_user_uploads column to events table...');
await knex.schema.alterTable('events', function(table) {
table.boolean('allow_user_uploads').defaultTo(false);
});
} else {
console.log('Column allow_user_uploads already exists in events table, skipping...');
}
const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id');
if (!hasUploadCategoryId) {
console.log('Adding upload_category_id column to events table...');
await knex.schema.alterTable('events', function(table) {
table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL');
});
} else {
console.log('Column upload_category_id already exists in events table, skipping...');
}
// Add uploaded_by field to photos table to track who uploaded
await knex.schema.alterTable('photos', function(table) {
table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier
});
const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by');
if (!hasUploadedBy) {
console.log('Adding uploaded_by column to photos table...');
await knex.schema.alterTable('photos', function(table) {
table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier
});
} else {
console.log('Column uploaded_by already exists in photos table, skipping...');
}
};
exports.down = async function(knex) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('allow_user_uploads');
table.dropColumn('upload_category_id');
});
await knex.schema.alterTable('photos', function(table) {
table.dropColumn('uploaded_by');
});
const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads');
if (hasAllowUserUploads) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('allow_user_uploads');
});
}
const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id');
if (hasUploadCategoryId) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('upload_category_id');
});
}
const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by');
if (hasUploadedBy) {
await knex.schema.alterTable('photos', function(table) {
table.dropColumn('uploaded_by');
});
}
};
+4 -2
View File
@@ -120,7 +120,9 @@ async function runMigrations() {
// Check if this is a new deployment
// It's new if no essential tables exist OR no migrations have been applied
const isNewDeployment = (!hasEventsTable || !hasPhotosTable || !hasAdminTable || !hasActivityLogsTable) || appliedFilenames.length === 0;
const hasEssentialTables = hasEventsTable && hasPhotosTable && hasAdminTable && hasActivityLogsTable;
const isDatabaseEmpty = !hasEventsTable && !hasPhotosTable && !hasAdminTable && !hasActivityLogsTable;
const isNewDeployment = isDatabaseEmpty || (appliedFilenames.length === 0 && !hasEssentialTables);
// Only detect existing schema for truly existing deployments
if (!isNewDeployment) {
@@ -227,4 +229,4 @@ if (require.main === module) {
waitAndRun();
}
module.exports = { runMigrations };
module.exports = { runMigrations };
+2306 -1274
View File
File diff suppressed because it is too large Load Diff
+17 -4
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.104",
"version": "1.1.15",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -15,16 +15,19 @@
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0",
"@ffmpeg-installer/ffmpeg": "^1.1.0",
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
"axios": "^1.10.0",
"axios": "^1.12.2",
"bcrypt": "6.0.0",
"chokidar": "4.0.3",
"cookie-parser": "^1.4.7",
"cors": "^2.8.5",
"dotenv": "^16.0.3",
"express": "^4.18.2",
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
"fluent-ffmpeg": "^2.1.3",
"form-data": "^4.0.4",
"handlebars": "^4.7.8",
"helmet": "^7.0.0",
@@ -32,15 +35,16 @@
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"joi": "^17.9.1",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.2",
"mime-types": "^3.0.1",
"multer": "^2.0.2",
"node-cron": "^3.0.2",
"nodemailer": "7.0.5",
"nodemailer": "^7.0.10",
"pg": "^8.16.3",
"react-i18next": "^15.6.0",
"sanitize-html": "^2.17.0",
"sharp": "0.34.3",
"sqlite3": "^5.1.6",
"uuid": "^11.1.0",
@@ -50,7 +54,16 @@
"devDependencies": {
"eslint": "^8.40.0",
"jest": "^29.5.0",
"mock-fs": "^5.5.0",
"nodemon": "^3.1.10",
"supertest": "^6.3.3"
},
"overrides": {
"prebuild-install": {
"tar-fs": "2.1.4"
},
"glob": "^11.1.0",
"body-parser": "^2.2.1",
"js-yaml": "^4.1.1"
}
}
+102
View File
@@ -0,0 +1,102 @@
#!/usr/bin/env node
const fs = require('fs');
const path = require('path');
const fsp = fs.promises;
async function pathExists(location) {
try {
await fsp.access(location);
return true;
} catch (error) {
if (error && error.code === 'ENOENT') {
return false;
}
throw error;
}
}
async function moveFile(source, destination) {
await fsp.mkdir(path.dirname(destination), { recursive: true });
try {
await fsp.rename(source, destination);
} catch (error) {
if (error.code === 'EXDEV') {
await fsp.copyFile(source, destination);
await fsp.unlink(source);
} else if (error.code === 'EEXIST') {
console.warn(`Destination already exists, leaving original in place: ${destination}`);
return;
} else {
throw error;
}
}
}
async function migrate() {
const backendRoot = path.resolve(__dirname, '..');
const defaultStorage = path.resolve(backendRoot, '../storage');
const targetStorage = path.resolve(process.env.STORAGE_PATH || defaultStorage);
const legacyUploadsRoot = path.resolve(backendRoot, 'storage/uploads');
const targetUploadsRoot = path.join(targetStorage, 'uploads');
if (legacyUploadsRoot === targetUploadsRoot) {
console.log('Legacy uploads directory already matches target STORAGE_PATH. Nothing to migrate.');
return;
}
if (!fs.existsSync(legacyUploadsRoot)) {
console.log(`Legacy uploads directory not found at ${legacyUploadsRoot}. Nothing to migrate.`);
return;
}
const categories = ['logos', 'favicons'];
let migratedCounter = 0;
for (const category of categories) {
const legacyDir = path.join(legacyUploadsRoot, category);
if (!fs.existsSync(legacyDir)) {
continue;
}
const targetDir = path.join(targetUploadsRoot, category);
await fsp.mkdir(targetDir, { recursive: true });
const entries = await fsp.readdir(legacyDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile()) {
continue;
}
const sourcePath = path.join(legacyDir, entry.name);
const destinationPath = path.join(targetDir, entry.name);
if (await pathExists(destinationPath)) {
console.warn(`Skipping ${sourcePath} because ${destinationPath} already exists.`);
continue;
}
await moveFile(sourcePath, destinationPath);
migratedCounter += 1;
}
const remaining = await fsp.readdir(legacyDir);
if (remaining.length === 0) {
await fsp.rm(legacyDir, { recursive: true, force: true });
}
}
if (migratedCounter === 0) {
console.log('No legacy logo or favicon files needed migration.');
return;
}
console.log(`Migrated ${migratedCounter} files into ${targetUploadsRoot}.`);
console.log('If the database still references legacy absolute paths, they will be cleaned up automatically on the next upload.');
}
migrate().catch((error) => {
console.error('Migration failed:', error);
process.exitCode = 1;
});
+54 -22
View File
@@ -7,12 +7,32 @@ const fs = require('fs').promises;
const path = require('path');
const readline = require('readline');
const rl = readline.createInterface({
const args = process.argv.slice(2);
const hasFlag = (flag) => args.includes(flag);
const getOption = (name) => {
const index = args.indexOf(`--${name}`);
if (index !== -1 && index + 1 < args.length) {
return args[index + 1];
}
return null;
};
const force = hasFlag('--force') || hasFlag('--yes') || hasFlag('--non-interactive');
const credentialsFileArg = getOption('credentials-file');
const resolvedCredentialsFile = credentialsFileArg
? path.resolve(process.cwd(), credentialsFileArg)
: path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
const rl = force ? null : readline.createInterface({
input: process.stdin,
output: process.stdout
});
async function question(prompt) {
async function ask(prompt) {
if (force) {
return 'yes';
}
return new Promise((resolve) => {
rl.question(prompt, resolve);
});
@@ -37,13 +57,18 @@ async function resetAdminPassword() {
console.log('Found admin user:', admin.username);
console.log('Email:', admin.email);
console.log('\nThis will reset the password for this admin account.');
const confirm = await question('\nDo you want to continue? (yes/no): ');
if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') {
console.log('\n❌ Password reset cancelled.');
process.exit(0);
if (!force) {
console.log('\nThis will reset the password for this admin account.');
}
const confirm = await ask('\nDo you want to continue? (yes/no): ');
if (!force) {
const normalized = confirm.trim().toLowerCase();
if (normalized !== 'yes' && normalized !== 'y') {
console.log('\n❌ Password reset cancelled.');
process.exit(0);
}
}
// Generate new password
@@ -60,39 +85,44 @@ async function resetAdminPassword() {
});
// Save to file
const resetInfoPath = path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
const credentialsDir = path.dirname(resolvedCredentialsFile);
await fs.mkdir(credentialsDir, { recursive: true });
const adminUrl = `${process.env.ADMIN_URL || 'http://localhost:3001'}/admin`;
const resetInfo = `
========================================
PicPeak Admin Password Reset
PicPeak Admin Credentials
========================================
Password has been reset for admin account:
Your admin account has been reset with these credentials:
Username: admin
New Password: ${newPassword}
Username: ${admin.username}
Email: ${admin.email}
Password: ${newPassword}
IMPORTANT:
1. You MUST change this password on next login
IMPORTANT SECURITY NOTES:
1. You MUST change this password after first login
2. This file contains sensitive information
3. Delete this file after noting the password
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
Login URL: ${adminUrl}
Reset performed on: ${new Date().toISOString()}
========================================
`;
await fs.writeFile(resetInfoPath, resetInfo, 'utf8');
await fs.writeFile(resolvedCredentialsFile, resetInfo, 'utf8');
console.log('\n✅ Password reset successful!\n');
console.log('========================================');
console.log('New Credentials:');
console.log('========================================');
console.log('Username: admin');
console.log(`Username: ${admin.username}`);
console.log(`Email: ${admin.email}`);
console.log(`Password: ${newPassword}`);
console.log('\n⚠️ IMPORTANT:');
console.log('1. You will be required to change this password on next login');
console.log('2. Credentials are also saved in: ADMIN_PASSWORD_RESET.txt');
console.log(`2. Credentials are also saved in: ${resolvedCredentialsFile}`);
console.log('3. Delete the file after noting the password');
console.log('========================================\n');
@@ -100,10 +130,12 @@ Reset performed on: ${new Date().toISOString()}
console.error('❌ Error resetting password:', error.message);
process.exit(1);
} finally {
rl.close();
if (rl) {
rl.close();
}
process.exit(0);
}
}
// Run the reset
resetAdminPassword();
resetAdminPassword();
+1 -1
View File
@@ -36,4 +36,4 @@ async function setAdminPassword() {
}
}
setAdminPassword();
setAdminPassword();
+276 -23
View File
@@ -12,6 +12,7 @@ logger.info('Server starting up', {
timestamp: new Date().toISOString()
});
const fs = require('fs');
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
@@ -25,6 +26,12 @@ const { startScheduledBackups } = require('./src/services/databaseBackup');
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
const { getPublicSitePayload } = require('./src/services/publicSiteService');
const cookieParser = require('cookie-parser');
const {
getAdminTokenFromRequest,
getGalleryTokenFromRequest,
} = require('./src/utils/tokenUtils');
// Import routes
const authRoutes = require('./src/routes/auth-enhanced');
@@ -42,25 +49,79 @@ const PORT = process.env.PORT || 3000;
app.set('trust proxy', 'loopback, linklocal, uniquelocal');
// Security middleware with custom CSP
// In native HTTP installs, do NOT force HTTPS for subresources.
const enableHsts = process.env.ENABLE_HSTS === 'true';
const cspDirectives = {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
'https://www.google.com',
'https://www.gstatic.com'
],
styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components
imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images
connectSrc: ["'self'", 'https://www.google.com', 'https://www.gstatic.com'], // API connections
fontSrc: ["'self'", "https:", "data:"], // Web fonts
objectSrc: ["'none'"], // Disable plugins
mediaSrc: ["'self'"], // Audio/video
frameSrc: ["'self'", 'https://www.google.com'],
};
// Only upgrade insecure requests when HSTS explicitly enabled (HTTPS deployment)
if (enableHsts) {
// In helmet, an empty array enables the directive
cspDirectives.upgradeInsecureRequests = [];
}
app.use(cookieParser());
app.use((req, res, next) => {
if (req.headers.authorization) {
return next();
}
const path = req.path || '';
const slugMatch = path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
const adminToken = getAdminTokenFromRequest(req);
const galleryToken = getGalleryTokenFromRequest(req, slug);
const isAdminRequest = path.startsWith('/api/admin') || path.startsWith('/admin');
const isGalleryRequest = Boolean(slugMatch)
|| path.startsWith('/api/gallery')
|| path.startsWith('/gallery')
|| path.startsWith('/api/secure-images');
// Prefer admin credentials on admin routes so gallery sessions cannot override them.
if (isAdminRequest) {
if (adminToken) {
req.headers.authorization = `Bearer ${adminToken}`;
}
} else if (isGalleryRequest) {
if (galleryToken) {
req.headers.authorization = `Bearer ${galleryToken}`;
} else if (adminToken) {
req.headers.authorization = `Bearer ${adminToken}`;
}
} else if (adminToken) {
req.headers.authorization = `Bearer ${adminToken}`;
} else if (galleryToken) {
req.headers.authorization = `Bearer ${galleryToken}`;
}
next();
});
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'", "'unsafe-inline'"], // Required for React
styleSrc: ["'self'", "'unsafe-inline'", "https:"], // Required for styled components
imgSrc: ["'self'", "data:", "https:", "blob:"], // Allow data URLs and external images
connectSrc: ["'self'"], // API connections
fontSrc: ["'self'", "https:", "data:"], // Web fonts
objectSrc: ["'none'"], // Disable plugins
mediaSrc: ["'self'"], // Audio/video
frameSrc: ["'none'"], // Disable iframes
},
// Avoid helmet adding defaults like upgrade-insecure-requests when not desired
useDefaults: false,
directives: cspDirectives,
},
hsts: {
hsts: enableHsts ? {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true
},
} : false,
permittedCrossDomainPolicies: false,
referrerPolicy: { policy: "strict-origin-when-cross-origin" }
}));
@@ -72,14 +133,14 @@ app.use((req, res, next) => {
next();
});
// CORS configuration
// CORS configuration (apply only to API routes)
const corsOptions = {
origin: function (origin, callback) {
const allowedOrigins = [
process.env.FRONTEND_URL || 'http://localhost:3005',
process.env.ADMIN_URL || 'http://localhost:3005'
];
// In development, also allow localhost origins
if (process.env.NODE_ENV === 'development') {
allowedOrigins.push(
@@ -89,23 +150,166 @@ const corsOptions = {
'http://localhost:3000' // Direct backend access
);
}
// Allow requests with no origin (like mobile apps or curl)
// Allow requests with no origin (like curl) and allow-listed origins
if (!origin || allowedOrigins.indexOf(origin) !== -1) {
callback(null, true);
} else {
callback(new Error('Not allowed by CORS'));
// Do not error globally; just omit CORS headers on disallowed origins
callback(null, false);
}
},
credentials: true
};
app.use(cors(corsOptions));
// Only attach CORS to API endpoints, not static assets
app.use('/api', cors(corsOptions));
// Handle preflight explicitly for API paths
app.options('/api/*', cors(corsOptions));
// Initialize rate limiters (they will be created dynamically)
let generalRateLimiter;
let authRateLimiter;
function composeInlineStyles(payload) {
const { branding } = payload;
const cssSegments = [];
cssSegments.push(`:root {
--brand-primary: ${branding.colors.primary};
--brand-accent: ${branding.colors.accent};
--brand-background: ${branding.colors.background};
--brand-text: ${branding.colors.text};
}`);
if (payload.baseCss) {
cssSegments.push(payload.baseCss);
}
if (payload.css) {
cssSegments.push(`/* Custom styles */\n${payload.css}`);
}
return cssSegments.join('\n\n');
}
function renderBrandHeader(branding) {
const displayName = branding.companyName || 'PicPeak';
const logoSrc = 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>`
: '';
return `<header class="site-header">
<div class="header-inner">
<div class="brand">
${logo}
<div class="brand-copy">
<p class="brand-label">${displayName}</p>
${tagline}
</div>
</div>
<nav class="site-nav">
<a href="#features">${'Features'}</a>
<a href="#workflow">${'Workflow'}</a>
<a href="#collections">${'Collections'}</a>
<a href="#stories">${'Stories'}</a>
<a href="#contact">${'Contact'}</a>
</nav>
</div>
</header>`;
}
function renderBrandFooter(branding) {
const displayName = branding.companyName || 'PicPeak';
const footerNote = branding.footerText
? `<p>${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 legalLinks = `
<a href="/datenschutz">Privacy Policy</a>
<a href="/impressum">Impressum</a>
${supportLink}
`;
return `<footer class="site-footer" id="contact">
<div class="footer-inner">
<div>
<h2>${displayName}</h2>
${footerNote}
</div>
<div class="footer-links">
${legalLinks}
</div>
</div>
</footer>`;
}
function buildPublicSiteDocument(payload) {
const inlineStyles = composeInlineStyles(payload);
const header = renderBrandHeader(payload.branding);
const footer = renderBrandFooter(payload.branding);
return `<!DOCTYPE html>
<html lang="en">
<head>
<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>
<meta name="description" content="Curated photo galleries and stories from unforgettable celebrations." />
<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" />
<style>${inlineStyles}</style>
</head>
<body>
<div class="site-shell">
${header}
<main class="site-main">
${payload.html}
</main>
${footer}
</div>
</body>
</html>`;
}
async function handlePublicSiteRequest(req, res, next) {
try {
const payload = await getPublicSitePayload();
if (!payload.enabled) {
res.redirect(302, '/admin/login');
return;
}
if (payload.etag && req.headers['if-none-match'] === payload.etag) {
res.status(304).end();
return;
}
const document = buildPublicSiteDocument(payload);
res.setHeader('Content-Type', 'text/html; charset=utf-8');
res.setHeader('Cache-Control', 'public, max-age=30, must-revalidate');
res.setHeader('ETag', payload.etag);
res.setHeader('Vary', 'Accept-Encoding');
res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'; img-src 'self' data: https:; style-src 'self' 'unsafe-inline' https:; font-src 'self' https: data:; object-src 'none'; script-src 'self'; form-action 'self'");
res.status(200).send(document);
} catch (error) {
logger.error('Failed to render public site', { error: error.message });
next();
}
}
// Function to initialize rate limiters
async function initializeRateLimiters() {
generalRateLimiter = await createRateLimiter();
@@ -120,9 +324,25 @@ async function initializeRateLimiters() {
// Note: Rate limiters will be initialized after database connection
// Body parsing middleware with increased limits for large uploads
app.use(express.json({ limit: '100mb' }));
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
<<<<<<< HEAD
app.use(express.json({ limit: '10gb' }));
app.use(express.urlencoded({ extended: true, limit: '10gb' }));
// Request logging for API routes (with timestamps)
const apiRequestLogger = (req, res, next) => {
try {
const started = Date.now();
const ts = new Date().toISOString();
logger.info(`[${ts}] ${req.method} ${req.originalUrl}`);
res.on('finish', () => {
const ms = Date.now() - started;
const tsDone = new Date().toISOString();
logger.info(`[${tsDone}] ${req.method} ${req.originalUrl} -> ${res.statusCode} (${ms}ms)`);
});
} catch (_) {}
next();
};
app.use('/api', apiRequestLogger);
// Maintenance mode middleware - add after body parsing but before routes
app.use(maintenanceMiddleware);
@@ -143,6 +363,7 @@ const secureStatic = require('./src/middleware/secureStatic');
// Get storage path from environment or use default
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
process.env.EXTERNAL_MEDIA_ROOT = process.env.EXTERNAL_MEDIA_ROOT || '/external-media';
// Static file serving for photos (protected)
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
@@ -199,7 +420,8 @@ app.get('/health', async (req, res) => {
// Routes
app.use('/api/auth', authRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/events', eventRoutes);
app.use('/api/admin/external-media', require('./src/routes/adminExternalMedia'));
// Gallery routes - main routes first, then feedback routes
app.use('/api/gallery', galleryRoutes);
app.use('/api/gallery', require('./src/routes/galleryFeedback'));
@@ -217,6 +439,37 @@ app.use('/api/public', require('./src/routes/publicCMS'));
app.use('/api/images', require('./src/routes/protectedImages'));
app.use('/api/secure-images', secureImagesRoutes);
// Optional: Serve built frontend (native installs)
try {
const serveFrontendEnv = process.env.SERVE_FRONTEND; // 'true' | 'false' | undefined
const frontendDir = process.env.FRONTEND_DIR || path.join(__dirname, '../frontend/dist');
const indexPath = path.join(frontendDir, 'index.html');
// Auto-serve when dist exists unless explicitly disabled
const shouldServe = (serveFrontendEnv === 'true') || ((serveFrontendEnv === undefined || serveFrontendEnv === 'auto') && fs.existsSync(indexPath));
if (shouldServe) {
logger.info(`Serving frontend from ${frontendDir}`);
// Serve pre-built assets
app.use(express.static(frontendDir));
// Landing page handler or SPA fallback
app.get('/', handlePublicSiteRequest, (req, res) => {
res.sendFile(indexPath);
});
// SPA fallback for admin + gallery routes
app.get(['/admin', '/admin/*', '/gallery/*'], (req, res) => {
res.sendFile(indexPath);
});
} else {
logger.info('Frontend static serving disabled or dist not found', { serveFrontendEnv, frontendDir });
app.get('/', handlePublicSiteRequest, (req, res) => {
res.status(503).send('Frontend bundle not available. Build frontend or enable public site.');
});
}
} catch (e) {
logger.warn('Failed to enable frontend static serving', { error: e.message });
}
// Error handling middleware
app.use((err, req, res, next) => {
console.error('EXPRESS ERROR HANDLER:', err);
@@ -0,0 +1,107 @@
jest.mock('../database/db', () => {
const mockDb = jest.fn();
return {
db: mockDb,
logActivity: jest.fn(),
};
});
jest.mock('../utils/logger', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}));
const { db } = require('../database/db');
const { getPublicSitePayload, clearPublicSiteCache } = require('../services/publicSiteService');
const { sanitizeCss } = require('../utils/cssSanitizer');
const buildPublicSiteRows = (overrides = {}) => ([
{ setting_key: 'general_public_site_enabled', setting_value: JSON.stringify(overrides.enabled ?? true) },
{ setting_key: 'general_public_site_html', setting_value: JSON.stringify(overrides.html ?? '<h1>{{company_name}}</h1>') },
{ setting_key: 'general_public_site_custom_css', setting_value: JSON.stringify(overrides.css ?? "body { color: red; }") }
]);
const buildBrandingRows = (overrides = {}) => ([
{ setting_key: 'branding_company_name', setting_value: JSON.stringify(overrides.companyName ?? 'Willow & Pine Studio') },
{ setting_key: 'branding_company_tagline', setting_value: JSON.stringify(overrides.companyTagline ?? 'Stories told in colour and light.') },
{ setting_key: 'branding_support_email', setting_value: JSON.stringify(overrides.supportEmail ?? 'hello@example.com') },
{ setting_key: 'branding_logo_url', setting_value: JSON.stringify(overrides.logoUrl ?? '/uploads/logos/logo.png') },
{ setting_key: 'branding_footer_text', setting_value: JSON.stringify(overrides.footerText ?? 'Crafted with care for every celebration.') },
{ setting_key: 'theme_config', setting_value: JSON.stringify(overrides.themeConfig ?? {
primaryColor: '#2563eb',
accentColor: '#1d4ed8',
backgroundColor: '#f8fafc',
textColor: '#0f172a'
}) }
]);
describe('publicSiteService', () => {
beforeEach(() => {
clearPublicSiteCache();
jest.clearAllMocks();
});
it('sanitizes stored HTML by stripping script tags', async () => {
const publicSiteRows = buildPublicSiteRows({ html: '<h1>{{company_name}}</h1><script>alert(1)</script>' });
const brandingRows = buildBrandingRows();
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.enabled).toBe(true);
expect(payload.html).toContain('<h1>Willow & Pine Studio</h1>');
expect(payload.html).not.toContain('<script');
expect(payload.baseCss.length).toBeGreaterThan(0);
expect(payload.branding.companyName).toBe('Willow & Pine Studio');
});
it('sanitizes custom CSS and removes dangerous patterns', async () => {
const publicSiteRows = buildPublicSiteRows({
css: "body { color: blue; } @import url('https://malicious.example/style.css'); div { background: url(\"javascript:alert(1)\"); }"
});
const brandingRows = buildBrandingRows();
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.css).toContain('body { color: blue; }');
expect(payload.css).not.toContain('@import');
expect(payload.css).not.toContain('javascript:');
// Client-side util should match server sanitization expectations
const clientSanitized = sanitizeCss(publicSiteRows[2].setting_value ? JSON.parse(publicSiteRows[2].setting_value) : '');
expect(clientSanitized).not.toContain('@import');
expect(clientSanitized).not.toContain('javascript:');
});
it('injects branding tokens into the rendered payload', async () => {
const publicSiteRows = buildPublicSiteRows({ html: '<section><h1>{{company_name}}</h1><p>{{company_tagline}}</p><a href="mailto:{{support_email}}">Get in touch</a></section>' });
const brandingRows = buildBrandingRows({
companyName: 'Aurora Collective',
companyTagline: 'Modern photography for timeless celebrations.',
supportEmail: 'studio@aurora.co',
logoUrl: '/uploads/logos/aurora.png',
themeConfig: {
primaryColor: '#5C8762',
accentColor: '#1d4ed8',
backgroundColor: '#fafafa',
textColor: '#171717'
}
});
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(publicSiteRows) }));
db.mockImplementationOnce(() => ({ whereIn: () => Promise.resolve(brandingRows) }));
const payload = await getPublicSitePayload({ bypassCache: true });
expect(payload.html).toContain('Aurora Collective');
expect(payload.html).toContain('Modern photography for timeless celebrations.');
expect(payload.html).toContain('studio@aurora.co');
expect(payload.branding.logoUrl).toBe('/uploads/logos/aurora.png');
expect(payload.branding.colors.primary).toBe('#5C8762');
});
});
+690
View File
@@ -0,0 +1,690 @@
const DEFAULT_PUBLIC_SITE_TITLE = 'PicPeak — Curated Galleries, Effortless Sharing';
const DEFAULT_PUBLIC_SITE_HTML = `
<section class="hero" id="welcome">
<div class="hero__inner">
<span class="hero__badge">PicPeak Showcase</span>
<h1>Share the story of {{company_name}}</h1>
<p class="hero__lead">{{company_tagline}}</p>
<div class="hero__cta">
<a href="#features" class="button button--primary">Explore Features</a>
<a href="#collections" class="button button--ghost">View Sample Galleries</a>
</div>
<dl class="hero__stats">
<div>
<dt>Private invites</dt>
<dd>Secure links for every guest</dd>
</div>
<div>
<dt>Curated delivery</dt>
<dd>Highlight every favourite instantly</dd>
</div>
<div>
<dt>Fully branded</dt>
<dd>Colours, typography, and logo that match you</dd>
</div>
</dl>
</div>
<div class="hero__visual">
<article class="deck deck--primary">
<header class="deck__header">
<img src="{{brand_logo_url}}" alt="{{company_name}} logo" class="deck__logo" loading="lazy" decoding="async" />
<span class="deck__title">PicPeak Gallery</span>
</header>
<ul class="deck__list">
<li>Guided cover stories</li>
<li>Guest uploads with approvals</li>
<li>Protected high-res downloads</li>
</ul>
</article>
<article class="deck deck--secondary">
<p class="deck__quote">PicPeak makes delivery feel like part of the celebration. Our couples relive the day the moment they open the link.</p>
<p class="deck__author"> Studio Miraval</p>
</article>
</div>
</section>
<section class="features" id="features">
<div class="section-head">
<span class="section-badge">Why teams pick PicPeak</span>
<h2>Design-first galleries with the workflow you already love</h2>
<p>Bring the PicPeak admin experience to your clients with branded, secure, and responsive public pages.</p>
</div>
<div class="feature-grid">
<article>
<h3>Beautiful by default</h3>
<p>Every gallery inherits your PicPeak theme, typography, and colour palette automatically.</p>
</article>
<article>
<h3>Guided storytelling</h3>
<p>Create anchored sections, spotlight favourite collections, and embed testimonials that build trust.</p>
</article>
<article>
<h3>Secure sharing</h3>
<p>Password gates, expiring links, and download protection keep every celebration personal.</p>
</article>
</div>
</section>
<section class="workflow" id="workflow">
<div class="workflow__content">
<h2>Launch in minutes</h2>
<ol class="workflow__steps">
<li>
<h4>Brand it once</h4>
<p>PicPeak automatically applies your logo, colours, and support details.</p>
</li>
<li>
<h4>Curate sections</h4>
<p>Highlight hero stories, featured galleries, and timeline moments with simple HTML blocks.</p>
</li>
<li>
<h4>Share confidently</h4>
<p>Send a single link that greets guests before they enter their private gallery.</p>
</li>
</ol>
</div>
<div class="workflow__media">
<figure class="workflow__browser">
<img src="/picpeak-logo-transparent.png" alt="PicPeak interface" loading="lazy" decoding="async" />
<figcaption>PicPeak dashboard &mdash; trusted by studios worldwide.</figcaption>
</figure>
</div>
</section>
<section class="collections" id="collections">
<div class="section-head">
<span class="section-badge">Showcase highlights</span>
<h2>Curated sample galleries that mirror your client experience</h2>
<p>Drop in featured stories, welcome messages, and callouts that prepare guests for what comes next.</p>
</div>
<div class="collection-showcase">
<article>
<h3>Signature Galleries</h3>
<p>Use responsive cards to preview your most loved collections or vendor partnerships.</p>
</article>
<article>
<h3>Welcome timelines</h3>
<p>Guide guests from arrival to download with steps that feel effortless and on-brand.</p>
</article>
</div>
</section>
<section class="stories" id="stories">
<div class="section-head section-head--center">
<span class="section-badge">Client notes</span>
<h2>Experiences that keep guests coming back</h2>
</div>
<div class="story-grid">
<figure>
<blockquote>From the welcome page to the final download, everything felt like us. PicPeak turned our gallery into part of the celebration.</blockquote>
<figcaption> Harper &amp; Elias</figcaption>
</figure>
<figure>
<blockquote>The public landing page gives every collection a narrative. Our couples feel the care we put into every image.</blockquote>
<figcaption> Jordan Rivera, Photographer</figcaption>
</figure>
</div>
</section>
<section class="cta" id="contact">
<div class="cta__inner">
<div>
<h2>Ready to welcome your guests?</h2>
<p>Create a PicPeak landing page that matches your studio and introduces every celebration with confidence.</p>
</div>
<div class="cta__actions">
<a href="mailto:{{support_email}}" class="button button--primary">Contact us</a>
<a href="#features" class="button button--ghost">Review features</a>
</div>
</div>
</section>
<footer class="site-footer" id="legal">
<div class="footer-inner">
<div>
<h2>{{company_name}}</h2>
<p>Powered by PicPeak to keep every celebration beautifully organised.</p>
</div>
<div class="footer-links">
<a href="/datenschutz">Privacy Policy</a>
<a href="/impressum">Impressum</a>
<a href="mailto:{{support_email}}">Support</a>
</div>
</div>
</footer>
`;
const DEFAULT_PUBLIC_SITE_CSS = `
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
font-family: 'Inter', 'Segoe UI', system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
background: linear-gradient(180deg, var(--brand-background), #ffffff 55%);
color: var(--brand-text);
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
text-decoration: none;
}
img {
max-width: 100%;
display: block;
}
.site-shell {
min-height: 100vh;
display: flex;
flex-direction: column;
background: linear-gradient(180deg, rgba(15, 23, 42, 0.03), transparent 65%);
}
.site-header {
position: sticky;
top: 0;
z-index: 30;
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(18px);
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.header-inner {
max-width: 1100px;
margin: 0 auto;
padding: 1rem 1.5rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1.5rem;
}
.brand {
display: flex;
align-items: center;
gap: 0.75rem;
}
.brand-logo {
width: 48px;
height: 48px;
border-radius: 12px;
object-fit: contain;
background: rgba(148, 163, 184, 0.12);
padding: 6px;
}
.brand-copy {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.brand-label {
margin: 0;
font-weight: 600;
font-size: 1rem;
letter-spacing: -0.01em;
color: var(--brand-text);
}
.brand-tagline {
margin: 0;
font-size: 0.85rem;
color: rgba(15, 23, 42, 0.65);
}
.site-nav {
display: flex;
gap: 1rem;
font-size: 0.95rem;
color: rgba(15, 23, 42, 0.65);
}
.site-nav a {
position: relative;
padding: 0.25rem 0;
}
.site-nav a::after {
content: '';
position: absolute;
left: 0;
bottom: -6px;
width: 100%;
height: 2px;
background: transparent;
transition: background 0.2s ease;
}
.site-nav a:hover::after {
background: var(--brand-primary);
}
.site-main {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rem;
padding: 2.5rem 1.5rem 4rem;
}
@media (min-width: 960px) {
.site-main {
padding: 3rem 0 5rem;
gap: 5rem;
}
.hero,
.features,
.workflow,
.collections,
.stories,
.cta {
max-width: 1100px;
margin: 0 auto;
}
}
.hero {
display: grid;
gap: 2.5rem;
align-items: center;
}
@media (min-width: 960px) {
.hero {
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
}
}
.hero__inner {
display: flex;
flex-direction: column;
gap: 1.75rem;
}
.hero__badge {
display: inline-flex;
align-items: center;
padding: 0.55rem 0.9rem;
border-radius: 999px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(34, 197, 94, 0.18);
color: var(--brand-primary);
}
.hero h1 {
margin: 0;
font-size: clamp(2.65rem, 4.8vw, 3.6rem);
letter-spacing: -0.02em;
line-height: 1.08;
}
.hero__lead {
margin: 0;
max-width: 32rem;
color: rgba(15, 23, 42, 0.72);
font-size: 1.05rem;
line-height: 1.6;
}
.hero__cta {
display: flex;
flex-wrap: wrap;
gap: 0.85rem;
}
.hero__stats {
display: grid;
gap: 1rem;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
margin: 0;
padding: 0;
}
.hero__stats dt {
font-weight: 600;
color: var(--brand-text);
}
.hero__stats dd {
margin: 0.35rem 0 0;
color: rgba(15, 23, 42, 0.6);
font-size: 0.95rem;
}
.hero__visual {
display: grid;
gap: 1.5rem;
}
.deck {
border-radius: 20px;
padding: 1.75rem;
background: #fff;
box-shadow: 0 35px 60px -35px rgba(15, 23, 42, 0.35);
border: 1px solid rgba(15, 23, 42, 0.08);
display: grid;
gap: 1.35rem;
}
.deck--primary {
border-color: rgba(34, 197, 94, 0.2);
}
.deck--secondary {
background: linear-gradient(135deg, rgba(34, 197, 94, 0.08), rgba(15, 23, 42, 0.03));
}
.deck__header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.deck__logo {
width: 44px;
height: 44px;
border-radius: 12px;
background: rgba(34, 197, 94, 0.12);
padding: 6px;
}
.deck__title {
font-weight: 600;
letter-spacing: -0.01em;
}
.deck__list {
margin: 0;
padding-left: 1.1rem;
display: grid;
gap: 0.65rem;
color: rgba(15, 23, 42, 0.68);
}
.deck__quote {
margin: 0;
font-size: 1.05rem;
line-height: 1.7;
color: rgba(15, 23, 42, 0.78);
}
.deck__author {
margin: 0;
font-weight: 600;
color: var(--brand-text);
}
.section-head {
display: grid;
gap: 1rem;
max-width: 640px;
}
.section-head--center {
text-align: center;
margin: 0 auto;
}
.section-badge {
display: inline-flex;
padding: 0.45rem 0.9rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
background: rgba(34, 197, 94, 0.14);
color: var(--brand-primary);
}
.section-head h2 {
margin: 0;
font-size: clamp(2rem, 3vw, 2.6rem);
letter-spacing: -0.018em;
}
.section-head p {
margin: 0;
color: rgba(15, 23, 42, 0.65);
}
.feature-grid {
display: grid;
gap: 1.5rem;
margin-top: 2.5rem;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.feature-grid article {
background: rgba(255, 255, 255, 0.9);
border-radius: 16px;
padding: 1.75rem;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 18px 40px -30px rgba(15, 23, 42, 0.28);
}
.workflow {
display: grid;
gap: 2rem;
align-items: center;
}
@media (min-width: 960px) {
.workflow {
grid-template-columns: minmax(0, 0.9fr) minmax(0, 1.1fr);
}
}
.workflow__steps {
margin: 1.75rem 0 0;
padding: 0;
list-style: none;
display: grid;
gap: 1.5rem;
}
.workflow__steps h4 {
margin: 0 0 0.35rem;
font-size: 1.05rem;
color: var(--brand-text);
}
.workflow__steps p {
margin: 0;
color: rgba(15, 23, 42, 0.65);
}
.workflow__browser {
margin: 0;
background: rgba(15, 23, 42, 0.05);
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.1);
padding: 2rem;
text-align: center;
color: rgba(15, 23, 42, 0.55);
font-size: 0.85rem;
}
.collection-showcase {
margin-top: 2.5rem;
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.collection-showcase article {
background: rgba(255, 255, 255, 0.92);
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.08);
padding: 1.5rem;
box-shadow: 0 18px 45px -32px rgba(15, 23, 42, 0.3);
}
.story-grid {
margin-top: 2.5rem;
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
}
.story-grid figure {
margin: 0;
padding: 1.75rem;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
border: 1px solid rgba(15, 23, 42, 0.08);
box-shadow: 0 18px 42px -32px rgba(15, 23, 42, 0.28);
}
.story-grid blockquote {
margin: 0 0 1.2rem;
font-size: 1.05rem;
line-height: 1.7;
color: rgba(15, 23, 42, 0.8);
}
.story-grid figcaption {
font-weight: 600;
color: rgba(15, 23, 42, 0.7);
}
.cta {
background: linear-gradient(135deg, var(--brand-primary), var(--brand-accent));
color: #fff;
border-radius: 28px;
padding: clamp(2.5rem, 5vw, 3.5rem);
}
.cta__inner {
display: flex;
flex-direction: column;
gap: 1.75rem;
max-width: 720px;
}
.cta__inner h2 {
margin: 0;
font-size: clamp(2rem, 3vw, 2.5rem);
}
.cta__inner p {
margin: 0;
font-size: 1.05rem;
opacity: 0.95;
}
.cta__actions {
display: flex;
flex-wrap: wrap;
gap: 1rem;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.85rem 1.75rem;
border-radius: 999px;
font-weight: 600;
transition: transform 160ms ease, box-shadow 200ms ease, background 200ms ease, color 200ms ease;
border: 1px solid transparent;
}
.button:hover {
transform: translateY(-2px);
}
.button--primary {
background: var(--brand-primary);
color: #fff;
box-shadow: 0 25px 45px -25px rgba(15, 23, 42, 0.55);
}
.button--primary:hover {
background: var(--brand-accent);
}
.button--ghost {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.45);
color: inherit;
}
.site-footer {
padding: 3rem 1.5rem;
background: rgba(15, 23, 42, 0.05);
border-top: 1px solid rgba(15, 23, 42, 0.08);
}
.footer-inner {
max-width: 1100px;
margin: 0 auto;
display: grid;
gap: 1.5rem;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.footer-inner h2 {
margin: 0 0 0.5rem;
font-size: 1.1rem;
}
.footer-inner p {
margin: 0;
color: rgba(15, 23, 42, 0.65);
line-height: 1.6;
}
.footer-links {
display: flex;
flex-direction: column;
gap: 0.65rem;
font-weight: 600;
color: var(--brand-primary);
}
.footer-links a {
color: inherit;
}
.footer-links a:hover {
text-decoration: underline;
}
@media (max-width: 960px) {
.site-nav {
display: none;
}
.hero__visual {
grid-template-columns: minmax(0, 1fr);
}
.workflow {
grid-template-columns: minmax(0, 1fr);
}
.cta__inner {
gap: 1.5rem;
}
}
`;
module.exports = {
DEFAULT_PUBLIC_SITE_TITLE,
DEFAULT_PUBLIC_SITE_HTML,
DEFAULT_PUBLIC_SITE_CSS,
};
+217 -3
View File
@@ -1,6 +1,28 @@
const fs = require('fs');
const path = require('path');
const knex = require('knex');
const knexConfig = require('../../knexfile');
const logger = require('../utils/logger');
const { extractShareToken } = require('../utils/shareLinkUtils');
// Ensure SQLite directory exists when using file-based DB (native installs)
try {
const isPostgres = knexConfig && knexConfig.client === 'pg';
if (!isPostgres && knexConfig && knexConfig.connection) {
const filename = typeof knexConfig.connection === 'object'
? knexConfig.connection.filename
: (typeof knexConfig.connection === 'string' ? knexConfig.connection : null);
if (filename && typeof filename === 'string') {
const dir = path.dirname(filename);
if (dir && dir !== '.') {
fs.mkdirSync(dir, { recursive: true });
}
}
}
} catch (e) {
// Non-fatal: log and continue; SQLite will fail later if still missing
try { logger.warn('SQLite directory ensure failed', { error: e.message }); } catch (_) {}
}
// Create database connection with built-in retry logic
const db = knex(knexConfig);
@@ -42,18 +64,30 @@ async function initializeDatabase() {
table.string('event_type').notNullable();
table.string('event_name').notNullable();
table.date('event_date').notNullable();
table.string('customer_name');
table.string('customer_email');
table.string('host_email').notNullable();
table.string('host_name');
table.string('admin_email').notNullable();
table.string('password_hash').notNullable();
table.text('welcome_message');
table.text('color_theme');
table.string('share_link').unique().notNullable();
table.string('share_token').unique();
table.datetime('created_at').defaultTo(db.fn.now());
table.datetime('expires_at').notNullable();
table.boolean('is_active').defaultTo(true);
table.boolean('is_archived').defaultTo(false);
table.string('archive_path');
table.datetime('archived_at');
table.boolean('allow_user_uploads').defaultTo(false);
table.integer('upload_category_id');
table.boolean('allow_downloads').defaultTo(true);
table.boolean('disable_right_click').defaultTo(false);
table.boolean('watermark_downloads').defaultTo(false);
table.text('watermark_text');
table.integer('hero_photo_id').references('id').inTable('photos').onDelete('SET NULL');
table.boolean('require_password').defaultTo(true);
});
} else {
// Check if color_theme needs to be updated to TEXT type
@@ -70,12 +104,16 @@ async function initializeDatabase() {
event_type TEXT NOT NULL,
event_name TEXT NOT NULL,
event_date DATE NOT NULL,
customer_name TEXT,
customer_email TEXT,
host_name TEXT,
host_email TEXT NOT NULL,
admin_email TEXT NOT NULL,
password_hash TEXT NOT NULL,
welcome_message TEXT,
color_theme TEXT,
share_link TEXT UNIQUE NOT NULL,
share_token TEXT UNIQUE,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
expires_at DATETIME NOT NULL,
is_active BOOLEAN DEFAULT 1,
@@ -83,11 +121,42 @@ async function initializeDatabase() {
archive_path TEXT,
archived_at DATETIME,
allow_user_uploads BOOLEAN DEFAULT 0,
upload_category_id INTEGER
upload_category_id INTEGER,
allow_downloads BOOLEAN DEFAULT 1,
disable_right_click BOOLEAN DEFAULT 0,
watermark_downloads BOOLEAN DEFAULT 0,
watermark_text TEXT,
hero_photo_id INTEGER,
require_password BOOLEAN DEFAULT 1
)
`);
await db.raw('INSERT INTO events_new SELECT * FROM events');
const pragmaRows = await db.raw("PRAGMA table_info('events')");
const existingColumns = pragmaRows.map(row => row.name);
const selectColumns = existingColumns.map((col) => {
switch (col) {
case 'allow_user_uploads':
return "COALESCE(allow_user_uploads, 0) as allow_user_uploads";
case 'upload_category_id':
return "upload_category_id";
case 'allow_downloads':
return "COALESCE(allow_downloads, 1) as allow_downloads";
case 'disable_right_click':
return "COALESCE(disable_right_click, 0) as disable_right_click";
case 'watermark_downloads':
return "COALESCE(watermark_downloads, 0) as watermark_downloads";
case 'watermark_text':
return 'watermark_text';
case 'hero_photo_id':
return 'hero_photo_id';
case 'require_password':
return 'COALESCE(require_password, 1) as require_password';
default:
return col;
}
});
await db.raw(`INSERT INTO events_new (${existingColumns.join(', ')}) SELECT ${selectColumns.join(', ')} FROM events`);
await db.raw('DROP TABLE events');
await db.raw('ALTER TABLE events_new RENAME TO events');
} catch (error) {
@@ -97,6 +166,37 @@ async function initializeDatabase() {
}
}
const hasShareTokenColumn = await db.schema.hasColumn('events', 'share_token');
if (!hasShareTokenColumn) {
await db.schema.table('events', (table) => {
table.string('share_token').unique();
});
}
const hasHostNameColumn = await db.schema.hasColumn('events', 'host_name');
if (!hasHostNameColumn) {
await db.schema.table('events', (table) => {
table.string('host_name');
});
}
try {
const eventsWithoutToken = await db('events')
.whereNull('share_token')
.select('id', 'share_link');
for (const event of eventsWithoutToken) {
const token = extractShareToken(event.share_link);
if (token) {
await db('events')
.where({ id: event.id })
.update({ share_token: token });
}
}
} catch (error) {
logger.warn('Share token backfill skipped', { error: error.message });
}
// Photo metadata table
const hasPhotosTable = await db.schema.hasTable('photos');
if (!hasPhotosTable) {
@@ -108,6 +208,7 @@ async function initializeDatabase() {
table.string('thumbnail_path');
table.string('type').notNullable(); // 'collage' or 'individual'
table.integer('size_bytes');
table.string('uploaded_by').defaultTo('admin');
table.datetime('uploaded_at').defaultTo(db.fn.now());
table.integer('view_count').defaultTo(0);
table.integer('download_count').defaultTo(0);
@@ -138,11 +239,24 @@ async function initializeDatabase() {
table.string('email_type').notNullable(); // 'creation', 'warning', 'expiration', 'archive_complete'
table.json('email_data');
table.string('status').defaultTo('pending'); // 'pending', 'sent', 'failed'
table.datetime('created_at').defaultTo(db.fn.now());
table.datetime('scheduled_at').defaultTo(db.fn.now());
table.datetime('sent_at');
table.text('error_message');
table.integer('retry_count').defaultTo(0);
});
} else {
const hasCreatedAt = await db.schema.hasColumn('email_queue', 'created_at');
if (!hasCreatedAt) {
await db.schema.alterTable('email_queue', (table) => {
table.datetime('created_at').defaultTo(db.fn.now());
});
try {
await db('email_queue').whereNull('created_at').update({ created_at: db.fn.now() });
} catch (updateError) {
logger.debug('Email queue created_at backfill skipped', { error: updateError.message });
}
}
}
// Admin users table
@@ -160,6 +274,7 @@ async function initializeDatabase() {
table.datetime('updated_at').defaultTo(db.fn.now());
table.datetime('last_login');
table.string('last_login_ip');
table.string('language', 2).defaultTo('en');
});
} else {
// Check if updated_at column exists
@@ -195,6 +310,13 @@ async function initializeDatabase() {
table.string('last_login_ip');
});
}
const hasLanguage = await db.schema.hasColumn('admin_users', 'language');
if (!hasLanguage) {
await db.schema.table('admin_users', (table) => {
table.string('language', 2).defaultTo('en');
});
}
}
// Token revocation tables
@@ -270,6 +392,18 @@ async function initializeDatabase() {
table.datetime('updated_at').defaultTo(db.fn.now());
});
}
const defaultLanguageSetting = await db('app_settings')
.where('setting_key', 'default_language')
.first();
if (!defaultLanguageSetting) {
await db('app_settings').insert({
setting_key: 'default_language',
setting_value: JSON.stringify('en'),
setting_type: 'general',
updated_at: new Date(),
});
}
// Activity logs table
const hasActivityLogsTable = await db.schema.hasTable('activity_logs');
@@ -294,6 +428,86 @@ async function initializeDatabase() {
});
}
}
await ensureGlobalCategories();
}
// Ensure photo categories exist for new deployments
async function ensureGlobalCategories() {
const hasPhotoCategoriesTable = await db.schema.hasTable('photo_categories');
if (!hasPhotoCategoriesTable) {
await db.schema.createTable('photo_categories', (table) => {
table.increments('id').primary();
table.string('name', 100).notNullable();
table.string('slug', 100).notNullable();
table.boolean('is_global').defaultTo(true);
table.integer('event_id').references('id').inTable('events').onDelete('CASCADE');
table.timestamp('created_at').defaultTo(db.fn.now());
table.unique(['slug', 'event_id']);
});
}
const hasCategoryIdColumn = await db.schema.hasColumn('photos', 'category_id');
if (!hasCategoryIdColumn) {
await db.schema.alterTable('photos', (table) => {
table.integer('category_id').references('id').inTable('photo_categories');
});
}
const hasCmsPagesTable = await db.schema.hasTable('cms_pages');
if (!hasCmsPagesTable) {
await db.schema.createTable('cms_pages', (table) => {
table.increments('id').primary();
table.string('slug', 100).unique().notNullable();
table.text('title_en');
table.text('title_de');
table.text('content_en');
table.text('content_de');
table.timestamp('updated_at').defaultTo(db.fn.now());
});
}
const categoryCountRow = await db('photo_categories').count({ count: 'id' }).first();
const categoryCount = categoryCountRow ? Number(categoryCountRow.count) : 0;
if (categoryCount === 0) {
const defaultCategories = [
{ name: 'Ceremony', slug: 'ceremony', is_global: true },
{ name: 'Reception', slug: 'reception', is_global: true },
{ name: 'Portraits', slug: 'portraits', is_global: true },
{ name: 'Group Photos', slug: 'group-photos', is_global: true },
{ name: 'Details', slug: 'details', is_global: true },
{ name: 'Party', slug: 'party', is_global: true },
];
await db('photo_categories').insert(defaultCategories);
}
const cmsPages = await db('cms_pages').select('slug');
const existingSlugs = cmsPages.map((page) => page.slug);
const defaultPages = [
{
slug: 'impressum',
title_en: 'Legal Notice',
title_de: 'Impressum',
content_en: '<h2>Legal Notice</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Impressum</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date(),
},
{
slug: 'datenschutz',
title_en: 'Privacy Policy',
title_de: 'Datenschutzerklärung',
content_en: '<h2>Privacy Policy</h2><p>Please edit this content in the admin panel.</p>',
content_de: '<h2>Datenschutzerklärung</h2><p>Bitte bearbeiten Sie diesen Inhalt im Admin-Panel.</p>',
updated_at: new Date(),
},
];
for (const page of defaultPages) {
if (!existingSlugs.includes(page.slug)) {
await db('cms_pages').insert(page);
}
}
}
// Helper function to log activities
@@ -312,4 +526,4 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
}
}
module.exports = { db, initializeDatabase, logActivity, withRetry };
module.exports = { db, initializeDatabase, logActivity, withRetry };
+5 -3
View File
@@ -3,13 +3,14 @@ const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
/**
* Enhanced admin authentication middleware with revocation checking
*/
async function adminAuth(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
const token = getAdminTokenFromRequest(req);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
@@ -97,7 +98,8 @@ async function adminAuth(req, res, next) {
*/
async function galleryAuth(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
const slug = req.params?.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
@@ -164,4 +166,4 @@ module.exports = {
adminAuth,
galleryAuth,
// ... other exports
};
};
+7 -4
View File
@@ -2,6 +2,7 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
/**
* Enhanced admin authentication middleware
@@ -9,7 +10,7 @@ const logger = require('../utils/logger');
*/
async function adminAuth(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
const token = getAdminTokenFromRequest(req);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
@@ -89,7 +90,8 @@ async function adminAuth(req, res, next) {
*/
async function galleryAuth(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
const slug = req.params?.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
@@ -151,7 +153,8 @@ async function galleryAuth(req, res, next) {
*/
async function photoAuth(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
const slug = req.params?.slug || req.requestedSlug;
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'Authentication required' });
}
@@ -235,4 +238,4 @@ module.exports = {
galleryAuth,
photoAuth,
verifyGalleryAccess
};
};
+2 -1
View File
@@ -2,10 +2,11 @@ const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
async function adminAuth(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
const token = getAdminTokenFromRequest(req);
if (!token) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
+45 -13
View File
@@ -1,17 +1,52 @@
const jwt = require('jsonwebtoken');
const { db, withRetry } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
// Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) {
try {
const authHeader = req.headers.authorization;
const token = authHeader?.split(' ')[1];
const requestedSlug = req.params.slug || req.requestedSlug;
const token = getGalleryTokenFromRequest(req, requestedSlug);
let event;
if (!token) {
if (!requestedSlug) {
return res.status(401).json({ error: 'No token provided' });
}
event = await withRetry(async () => {
return await db('events')
.where({
slug: requestedSlug,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.select('*')
.first();
});
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (!requiresPassword) {
req.event = event;
req.sessionID = `gallery_public_${event.id}_${Date.now()}`;
req.clientInfo = {
ip: req.ip || req.connection.remoteAddress || 'unknown',
userAgent: req.get('User-Agent') || 'unknown',
fingerprint: `${req.ip}-${req.get('User-Agent')}`.substring(0, 32),
timestamp: Date.now()
};
return next();
}
return res.status(401).json({ error: 'No token provided' });
}
// Try to verify with issuer first, fallback to no issuer for backward compatibility
let decoded;
try {
@@ -26,12 +61,9 @@ async function verifyGalleryAccess(req, res, next) {
throw error;
}
}
console.log('[verifyGalleryAccess] Token decoded successfully, eventId:', decoded.eventId);
logger.debug('[verifyGalleryAccess] Token decoded successfully', { eventId: decoded.eventId, slug: requestedSlug });
// If we have a slug in the URL params or from pre-middleware, verify it matches
const requestedSlug = req.params.slug || req.requestedSlug;
let event;
if (requestedSlug) {
// Verify by slug and ensure it matches the token's event
event = await withRetry(async () => {
@@ -64,11 +96,11 @@ async function verifyGalleryAccess(req, res, next) {
}
if (!event) {
console.log('[verifyGalleryAccess] Event not found for slug:', requestedSlug || 'no-slug', 'eventId:', decoded.eventId);
logger.warn('[verifyGalleryAccess] Event not found for slug', { slug: requestedSlug || 'no-slug', tokenEventId: decoded.eventId });
return res.status(404).json({ error: 'Gallery not found or expired' });
}
console.log('[verifyGalleryAccess] Event found:', event.id, event.slug);
logger.debug('[verifyGalleryAccess] Event located', { eventId: event.id, slug: event.slug });
req.event = event;
req.sessionID = decoded.sessionId || `gallery_${event.id}_${Date.now()}`;
@@ -80,14 +112,14 @@ async function verifyGalleryAccess(req, res, next) {
timestamp: Date.now()
};
console.log('[verifyGalleryAccess] Access granted for event:', event.id);
logger.debug('[verifyGalleryAccess] Access granted', { eventId: event.id, slug: event.slug });
next();
} catch (error) {
console.error('Error verifying gallery access:', error);
res.status(401).json({ error: 'Invalid token', details: error.message });
logger.error('Error verifying gallery access', { error: error.message, stack: error.stack });
res.status(401).json({ error: 'Invalid token' });
}
}
module.exports = {
verifyGalleryAccess
};
};
+22 -15
View File
@@ -2,14 +2,14 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { getGalleryTokenFromRequest } = require('../utils/tokenUtils');
const logger = require('../utils/logger');
async function photoAuth(req, res, next) {
try {
// Extract event slug from the path
let eventSlug;
console.log('PhotoAuth middleware - path:', req.path);
// For thumbnails, we need to parse the filename to get the event info
if (req.path.startsWith('/thumb_')) {
// For now, we'll rely on JWT token for thumbnail access
@@ -20,9 +20,9 @@ async function photoAuth(req, res, next) {
}
// First check for JWT token (from gallery access)
const authHeader = req.headers.authorization;
if (authHeader && authHeader.startsWith('Bearer ')) {
const token = authHeader.replace('Bearer ', '');
const tokenFromRequest = getGalleryTokenFromRequest(req, eventSlug);
if (tokenFromRequest) {
const token = tokenFromRequest;
try {
// Try to verify with issuer first, fallback to no issuer for backward compatibility
let decoded;
@@ -79,29 +79,36 @@ async function photoAuth(req, res, next) {
// For both thumbnails and photos with admin token, allow access
return next();
}
} catch (err) {
// Token invalid, fall through to password check
console.error('JWT verification failed:', err.message);
} catch (err) {
// Token invalid, fall through to password check
logger.warn('JWT verification failed in photoAuth', { error: err.message });
}
}
// Check for password header (legacy support)
const password = req.headers['x-gallery-password'];
if (!password && !authHeader) {
return res.status(401).json({ error: 'Authentication required' });
}
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
if (!eventSlug && !password) {
if (!eventSlug && !password && !tokenFromRequest) {
return res.status(401).json({ error: 'Authentication required for thumbnails' });
}
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (!requiresPassword) {
req.event = event;
return next();
}
if (!password && !tokenFromRequest) {
return res.status(401).json({ error: 'Authentication required' });
}
if (password) {
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
@@ -121,7 +128,7 @@ async function photoAuth(req, res, next) {
req.event = event;
next();
} catch (error) {
console.error('Photo auth error:', error);
logger.error('Photo auth error', { error: error.message, stack: error.stack });
res.status(500).json({ error: 'Authentication error' });
}
}
+3 -6
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { getAdminTokenFromRequest } = require('../utils/tokenUtils');
// In-memory session tracking (in production, use Redis)
const sessions = new Map();
@@ -67,11 +68,7 @@ async function getSessionTimeout() {
async function sessionTimeoutMiddleware(req, res, next) {
// Skip for non-authenticated routes
if (!req.headers.authorization) {
return next();
}
const token = req.headers.authorization.split(' ')[1];
const token = getAdminTokenFromRequest(req);
if (!token) {
return next();
}
@@ -150,4 +147,4 @@ module.exports = {
sessionTimeoutMiddleware,
endSession,
getActiveSessions
};
};
@@ -0,0 +1,99 @@
const request = require('supertest');
const express = require('express');
const buildChain = ({ firstResult, updateResult } = {}) => {
const chain = {
where: jest.fn().mockReturnThis(),
whereNot: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
update: jest.fn().mockResolvedValue(updateResult ?? 1),
first: jest.fn().mockResolvedValue(firstResult),
};
return chain;
};
jest.mock('../../database/db', () => {
const dbMock = jest.fn();
dbMock.raw = jest.fn();
dbMock.__setImplementations = (...chains) => {
dbMock.mockReset();
chains.forEach((chain) => {
dbMock.mockImplementationOnce(() => chain);
});
};
return {
db: dbMock,
logActivity: jest.fn().mockResolvedValue(undefined),
};
});
jest.mock('../../middleware/auth-enhanced-v2', () => ({
adminAuth: (_req, _res, next) => {
_req.admin = { id: 1, username: 'admin' };
next();
},
}));
const { db, logActivity } = require('../../database/db');
const adminAuthRouter = require('../adminAuth');
describe('adminAuth profile updates', () => {
const app = express();
app.use(express.json());
app.use('/auth/admin', adminAuthRouter);
beforeEach(() => {
jest.clearAllMocks();
});
it('updates the admin profile', async () => {
const updatedUser = {
id: 1,
username: 'newadmin',
email: 'newadmin@example.com',
must_change_password: false,
};
db.__setImplementations(
buildChain({ firstResult: null }), // email check
buildChain({ firstResult: null }), // username check
buildChain({ updateResult: 1 }), // update
buildChain({ firstResult: updatedUser }), // fetch updated user
);
const response = await request(app)
.put('/auth/admin/profile')
.send({ username: updatedUser.username, email: updatedUser.email })
.expect(200);
expect(response.body).toEqual({ user: updatedUser });
expect(logActivity).toHaveBeenCalledWith(
'admin_profile_updated',
{ admin_id: 1, updated_fields: ['username', 'email'] },
null,
{ type: 'admin', id: 1, name: updatedUser.username }
);
});
it('rejects email conflicts', async () => {
db.__setImplementations(
buildChain({ firstResult: { id: 2 } })
);
const response = await request(app)
.put('/auth/admin/profile')
.send({ username: 'newadmin', email: 'taken@example.com' })
.expect(409);
expect(response.body).toEqual({ error: 'Email is already in use by another admin' });
});
it('validates input', async () => {
const response = await request(app)
.put('/auth/admin/profile')
.send({ username: '', email: 'not-an-email' })
.expect(400);
expect(response.body.errors).toBeDefined();
});
});
@@ -0,0 +1,67 @@
const request = require('supertest');
const express = require('express');
jest.mock('../../database/db', () => {
const deleteMock = jest.fn().mockResolvedValue(5);
const chain = {
select: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
whereNull: jest.fn().mockReturnThis(),
whereNotNull: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: deleteMock,
count: jest.fn().mockReturnThis(),
first: jest.fn().mockResolvedValue({ count: 0 }),
};
const dbMock = jest.fn(() => chain);
dbMock.raw = jest.fn();
dbMock.__chain = chain;
dbMock.__deleteMock = deleteMock;
return { db: dbMock };
});
jest.mock('../../middleware/auth-enhanced-v2', () => ({
adminAuth: (_req, _res, next) => next(),
}));
const { db } = require('../../database/db');
const notificationsRouter = require('../adminNotifications');
describe('adminNotifications routes', () => {
const app = express();
app.use(express.json());
app.use('/admin/notifications', notificationsRouter);
beforeEach(() => {
jest.clearAllMocks();
});
it('clears all notifications', async () => {
db.__deleteMock.mockResolvedValueOnce(8);
const response = await request(app)
.delete('/admin/notifications/clear-all')
.expect(200);
expect(db).toHaveBeenCalledWith('activity_logs');
expect(db.__deleteMock).toHaveBeenCalledTimes(1);
expect(response.body).toEqual({
message: 'All notifications cleared',
deletedCount: 8,
});
});
it('handles database errors when clearing notifications', async () => {
db.__deleteMock.mockRejectedValueOnce(new Error('boom'));
const response = await request(app)
.delete('/admin/notifications/clear-all')
.expect(500);
expect(response.body).toEqual({ error: 'Failed to clear notifications' });
});
});
+150 -1
View File
@@ -8,6 +8,93 @@ const { validatePasswordStrength } = require('../utils/passwordGenerator');
const router = express.Router();
// Change password
router.get('/profile', adminAuth, async (req, res) => {
try {
const admin = await db('admin_users')
.where('id', req.admin.id)
.select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword')
.first();
if (!admin) {
return res.status(404).json({ error: 'Admin user not found' });
}
res.json(admin);
} catch (error) {
console.error('Admin profile fetch error:', error);
res.status(500).json({ error: 'Failed to fetch admin profile' });
}
});
router.put('/profile', [
adminAuth,
body('username')
.trim()
.isLength({ min: 3, max: 50 })
.withMessage('Username must be between 3 and 50 characters'),
body('email')
.trim()
.isEmail()
.withMessage('A valid email address is required')
.normalizeEmail()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const username = req.body.username.trim();
const email = req.body.email.trim().toLowerCase();
const adminId = req.admin.id;
const existingUsername = await db('admin_users')
.where('username', username)
.whereNot('id', adminId)
.first();
if (existingUsername) {
return res.status(409).json({ error: 'Username is already in use' });
}
const existingEmail = await db('admin_users')
.where('email', email)
.whereNot('id', adminId)
.first();
if (existingEmail) {
return res.status(409).json({ error: 'Email address is already in use' });
}
await db('admin_users')
.where('id', adminId)
.update({
username,
email,
updated_at: new Date()
});
await logActivity('admin_profile_updated',
{ username, email },
null,
{ type: 'admin', id: adminId, name: req.admin.username }
);
const updatedAdmin = await db('admin_users')
.where('id', adminId)
.select('id', 'username', 'email', 'must_change_password as mustChangePassword')
.first();
res.json({
message: 'Admin profile updated successfully',
user: updatedAdmin
});
} catch (error) {
console.error('Admin profile update error:', error);
res.status(500).json({ error: 'Failed to update admin profile' });
}
});
router.post('/change-password', [
adminAuth,
body('currentPassword').notEmpty().withMessage('Current password is required'),
@@ -72,6 +159,68 @@ router.post('/change-password', [
}
});
// Update admin profile
router.put('/profile', [
adminAuth,
body('username').trim().notEmpty().withMessage('Username is required'),
body('email').trim().isEmail().withMessage('Valid email is required')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, email } = req.body;
const userId = req.admin.id;
// Check for email conflicts
const existingEmail = await db('admin_users')
.where('email', email)
.whereNot('id', userId)
.first();
if (existingEmail) {
return res.status(409).json({ error: 'Email is already in use by another admin' });
}
// Check username conflict (if multiple admins are supported)
const existingUsername = await db('admin_users')
.where('username', username)
.whereNot('id', userId)
.first();
if (existingUsername) {
return res.status(409).json({ error: 'Username is already in use by another admin' });
}
await db('admin_users')
.where('id', userId)
.update({
username,
email,
updated_at: new Date()
});
const updatedUser = await db('admin_users')
.select('id', 'username', 'email', 'must_change_password')
.where('id', userId)
.first();
await logActivity(
'admin_profile_updated',
{ admin_id: userId, updated_fields: ['username', 'email'] },
null,
{ type: 'admin', id: userId, name: username }
);
res.json({ user: updatedUser });
} catch (error) {
console.error('Admin profile update error:', error);
res.status(500).json({ error: 'Failed to update admin profile' });
}
});
// Logout
router.post('/logout', adminAuth, async (req, res) => {
try {
@@ -96,4 +245,4 @@ router.post('/logout', adminAuth, async (req, res) => {
}
});
module.exports = router;
module.exports = router;
+25 -16
View File
@@ -221,7 +221,11 @@ router.post('/test-connection', adminAuth, async (req, res) => {
await fs.access(config.path, fs.constants.W_OK);
res.json({ success: true, message: 'Local path is writable' });
} catch (error) {
res.json({ success: false, message: 'Cannot write to local path: ' + error.message });
logger.warn('Local backup path not writable', {
path: config.path,
error: error.message
});
res.json({ success: false, message: 'Cannot write to local path. Check server logs for details.' });
}
break;
@@ -243,7 +247,11 @@ router.post('/test-connection', adminAuth, async (req, res) => {
const { stdout } = await execAsync(testCommand);
res.json({ success: true, message: 'Rsync connection successful' });
} catch (error) {
res.json({ success: false, message: 'Rsync connection failed: ' + error.message });
logger.warn('Rsync connection test failed', {
destination: config.host || config.destination,
error: error.message
});
res.json({ success: false, message: 'Rsync connection failed. Check server logs for details.' });
}
break;
@@ -274,7 +282,7 @@ router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
});
} catch (error) {
logger.error('Failed to get backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' });
res.status(404).json({ error: 'Backup manifest not found' });
}
});
@@ -327,7 +335,7 @@ router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
}
} catch (error) {
logger.error('Failed to download backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' });
res.status(404).json({ error: 'Backup manifest not found' });
}
});
@@ -344,7 +352,7 @@ router.get('/manifests/:backupId', adminAuth, async (req, res) => {
});
} catch (error) {
logger.error('Failed to get backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' });
res.status(404).json({ error: 'Backup manifest not found' });
}
});
@@ -375,7 +383,7 @@ router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
}
} catch (error) {
logger.error('Failed to download backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' });
res.status(404).json({ error: 'Backup manifest not found' });
}
});
@@ -436,7 +444,7 @@ router.get('/s3/buckets', adminAuth, async (req, res) => {
});
} catch (error) {
logger.error('Failed to list S3 buckets:', error);
res.status(500).json({ error: 'Failed to list S3 buckets: ' + error.message });
res.status(500).json({ error: 'Failed to list S3 buckets' });
}
});
@@ -473,7 +481,7 @@ router.get('/s3/files', adminAuth, async (req, res) => {
});
} catch (error) {
logger.error('Failed to list S3 files:', error);
res.status(500).json({ error: 'Failed to list S3 files: ' + error.message });
res.status(500).json({ error: 'Failed to list S3 files' });
}
});
@@ -535,7 +543,7 @@ router.delete('/s3/cleanup', adminAuth, async (req, res) => {
});
} catch (error) {
logger.error('Failed to cleanup S3 backups:', error);
res.status(500).json({ error: 'Failed to cleanup S3 backups: ' + error.message });
res.status(500).json({ error: 'Failed to cleanup S3 backups' });
}
});
@@ -587,7 +595,7 @@ router.post('/s3/test-upload', adminAuth, async (req, res) => {
});
} catch (error) {
logger.error('S3 upload test failed:', error);
res.status(500).json({ error: 'S3 upload test failed: ' + error.message });
res.status(500).json({ error: 'S3 upload test failed' });
}
});
@@ -675,7 +683,7 @@ router.get('/download/:backupId', adminAuth, async (req, res) => {
}
} catch (error) {
logger.error('Failed to download backup:', error);
res.status(500).json({ error: 'Failed to download backup: ' + error.message });
res.status(500).json({ error: 'Failed to download backup' });
}
});
@@ -744,7 +752,7 @@ router.get('/checksums', adminAuth, async (req, res) => {
});
} catch (error) {
logger.error('Failed to get file checksums:', error);
res.status(500).json({ error: 'Failed to get file checksums: ' + error.message });
res.status(500).json({ error: 'Failed to get file checksums' });
}
});
@@ -847,7 +855,7 @@ router.post('/estimate', adminAuth, async (req, res) => {
});
} catch (error) {
logger.error('Failed to estimate backup size:', error);
res.status(500).json({ error: 'Failed to estimate backup size: ' + error.message });
res.status(500).json({ error: 'Failed to estimate backup size' });
}
});
@@ -945,12 +953,13 @@ async function validateManifestData(manifestData) {
}
};
} catch (error) {
logger.error('Manifest validation error', { error: error.message });
return {
valid: false,
error: `Validation error: ${error.message}`,
details: { error: error.message }
error: 'Validation error encountered while processing manifest',
details: { hint: 'See server logs for diagnostic details.' }
};
}
}
module.exports = router;
module.exports = router;
+48 -10
View File
@@ -18,7 +18,8 @@ router.get('/config', adminAuth, async (req, res) => {
smtp_user: '',
smtp_pass: '', // Don't send actual password
from_email: '',
from_name: ''
from_name: '',
tls_reject_unauthorized: true
});
}
@@ -53,7 +54,8 @@ router.post('/config', [
smtp_user,
smtp_pass,
from_email,
from_name
from_name,
tls_reject_unauthorized
} = req.body;
// Check if config exists
@@ -66,6 +68,7 @@ router.post('/config', [
smtp_user: smtp_user || '',
from_email,
from_name: from_name || 'Photo Sharing',
tls_reject_unauthorized: tls_reject_unauthorized !== false, // Default to true
updated_at: new Date()
};
@@ -137,6 +140,10 @@ router.post('/test', adminAuth, async (req, res) => {
user: config.smtp_user,
pass: config.smtp_pass
} : undefined,
tls: {
// Allow ignoring SSL certificate errors when tls_reject_unauthorized is false
rejectUnauthorized: config.tls_reject_unauthorized !== false
},
logger: process.env.NODE_ENV === 'development',
debug: process.env.NODE_ENV === 'development'
};
@@ -173,26 +180,57 @@ router.post('/test', adminAuth, async (req, res) => {
} catch (error) {
console.error('Test email error:', error);
console.error('Error stack:', error.stack);
// Provide more specific error messages
let errorMessage = 'Failed to send test email';
// Provide more specific error messages with translation keys
let errorMessage = 'Error sending email';
let errorKey = 'email.errors.sendFailed';
let details = error.message;
let detailsKey = 'email.errors.unknownError';
if (error.code === 'ECONNREFUSED') {
errorMessage = 'Failed to connect to SMTP server';
errorKey = 'email.errors.connectionRefused';
details = 'Please check your SMTP host and port settings';
detailsKey = 'email.errors.checkHostPort';
} else if (error.code === 'EAUTH') {
errorMessage = 'SMTP authentication failed';
errorKey = 'email.errors.authFailed';
details = 'Please check your SMTP username and password';
detailsKey = 'email.errors.checkCredentials';
} else if (error.code === 'ESOCKET') {
errorMessage = 'Network error';
errorMessage = 'Network error connecting to SMTP server';
errorKey = 'email.errors.networkError';
details = 'Could not establish connection to SMTP server';
detailsKey = 'email.errors.connectionFailed';
} else if (error.code === 'ETIMEDOUT') {
errorMessage = 'Connection to SMTP server timed out';
errorKey = 'email.errors.timeout';
details = 'The server took too long to respond. Please check your network and SMTP settings.';
detailsKey = 'email.errors.timeoutDetails';
} else if (error.code === 'ENOTFOUND') {
errorMessage = 'SMTP server not found';
errorKey = 'email.errors.serverNotFound';
details = 'The SMTP host could not be resolved. Please verify the hostname.';
detailsKey = 'email.errors.checkHostname';
} else if (error.responseCode >= 500) {
errorMessage = 'SMTP server error';
errorKey = 'email.errors.serverError';
details = `Server returned error code ${error.responseCode}`;
detailsKey = 'email.errors.serverErrorDetails';
} else if (error.responseCode >= 400) {
errorMessage = 'Email rejected by server';
errorKey = 'email.errors.rejected';
details = error.response || 'The email was rejected. Check recipient address and settings.';
detailsKey = 'email.errors.rejectedDetails';
}
res.status(500).json({
res.status(500).json({
error: errorMessage,
errorKey: errorKey,
details: details,
code: error.code
detailsKey: detailsKey,
code: error.code,
responseCode: error.responseCode
});
}
});
+14 -10
View File
@@ -2,13 +2,14 @@
// Only the relevant parts are shown - merge with existing adminEvents.js
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { buildShareLinkVariants } = require('../services/shareLinkService');
// Enhanced event creation with password validation
router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
body('host_email').isEmail().normalizeEmail(),
body('customer_email').isEmail().normalizeEmail(),
body('admin_email').isEmail().normalizeEmail(),
body('password').notEmpty(), // Remove the weak isLength validation
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
@@ -16,7 +17,7 @@ router.post('/', adminAuth, [
body('color_theme').optional().trim(),
body('allow_user_uploads').optional().isBoolean().toBoolean(),
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
body('host_name').notEmpty().trim()
body('customer_name').notEmpty().trim()
], async (req, res) => {
try {
console.log('Create event request body:', req.body);
@@ -30,8 +31,8 @@ router.post('/', adminAuth, [
event_type,
event_name,
event_date,
host_name,
host_email,
customer_name,
customer_email,
admin_email,
password,
welcome_message = '',
@@ -65,9 +66,9 @@ router.post('/', adminAuth, [
counter++;
}
// Generate share link
// Generate share link based on configured style
const shareToken = crypto.randomBytes(16).toString('hex');
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`;
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds
const password_hash = await bcrypt.hash(password, getBcryptRounds());
@@ -88,13 +89,16 @@ router.post('/', adminAuth, [
event_type,
event_name,
event_date,
host_name,
host_email,
customer_name,
customer_email,
host_name: customer_name,
host_email: customer_email,
admin_email,
password_hash,
welcome_message,
color_theme,
share_link: shareLink,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(),
allow_user_uploads,
@@ -121,4 +125,4 @@ router.post('/', adminAuth, [
console.error('Error creating event:', error);
res.status(500).json({ error: 'Failed to create event' });
}
});
});
+277 -55
View File
@@ -13,28 +13,129 @@ const { queueEmail } = require('../services/emailProcessor');
const { escapeLikePattern } = require('../utils/sqlSecurity');
// formatDate import removed - dates are formatted by email processor
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const logger = require('../utils/logger');
const { buildShareLinkVariants } = require('../services/shareLinkService');
const parseBooleanInput = (value, defaultValue = true) => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) {
return false;
}
if (['true', '1', 'yes', 'on'].includes(normalized)) {
return true;
}
}
return defaultValue;
};
const getCustomerNameFromPayload = (payload = {}) => {
if (typeof payload.customer_name === 'string') {
const trimmed = payload.customer_name.trim();
return trimmed || null;
}
return null;
};
const getCustomerEmailFromPayload = (payload = {}) => {
if (typeof payload.customer_email === 'string') {
const trimmed = payload.customer_email.trim();
return trimmed || null;
}
return null;
};
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
return event;
}
const {
host_name,
host_email,
customer_name,
customer_email,
...rest
} = event;
return {
...rest,
customer_name: customer_name ?? host_name ?? null,
customer_email: customer_email ?? host_email ?? null
};
};
let customerColumnCache = null;
const hasCustomerContactColumns = async () => {
if (customerColumnCache === true) {
return true;
}
try {
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
if (hasColumn) {
customerColumnCache = true;
}
return hasColumn;
} catch (error) {
logger.debug('Failed to detect customer_email column', { error: error.message });
return false;
}
};
// Create new event
router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty().trim(),
body('event_date').isDate(),
body('host_email').isEmail().normalizeEmail(),
body('customer_name').notEmpty().trim(),
body('customer_email').isEmail().normalizeEmail(),
body('admin_email').isEmail().normalizeEmail(),
body('password').isLength({ min: 6 }),
body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
const input = req.body.require_password;
const normalizeBoolean = (val, defaultValue = true) => {
if (val === undefined || val === null) return defaultValue;
if (typeof val === 'boolean') return val;
if (typeof val === 'number') return val !== 0;
if (typeof val === 'string') {
const normalized = val.trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
}
return defaultValue;
};
const requirePassword = normalizeBoolean(input, true);
if (!requirePassword) {
return true;
}
if (typeof value !== 'string' || value.trim().length < 6) {
throw new Error('Password must be at least 6 characters long');
}
return true;
}),
body('expiration_days').isInt({ min: 1, max: 365 }).optional(),
body('welcome_message').optional().trim(),
body('color_theme').optional().trim(),
body('allow_user_uploads').optional().isBoolean().toBoolean(),
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
body('host_name').notEmpty().trim(),
body('allow_downloads').optional().isBoolean(),
body('disable_right_click').optional().isBoolean(),
body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim()
], async (req, res) => {
try {
console.log('Create event request body:', req.body);
logger.debug('Create event request body', { body: req.body });
const errors = validationResult(req);
if (!errors.isEmpty()) {
console.error('Validation errors:', errors.array());
@@ -45,8 +146,6 @@ router.post('/', adminAuth, [
event_type,
event_name,
event_date,
host_name,
host_email,
admin_email,
password,
welcome_message = '',
@@ -58,6 +157,7 @@ router.post('/', adminAuth, [
disable_right_click = false,
watermark_downloads = false,
watermark_text = null,
require_password: requirePasswordInput = true,
// Feedback settings
feedback_enabled = false,
allow_ratings = true,
@@ -68,13 +168,25 @@ router.post('/', adminAuth, [
moderate_comments = true,
show_feedback_to_guests = true
} = req.body;
const customerName = getCustomerNameFromPayload(req.body);
const customerEmail = getCustomerEmailFromPayload(req.body);
const customerColumnsAvailable = await hasCustomerContactColumns();
if (!customerName || !customerEmail) {
return res.status(400).json({ error: 'customer_name and customer_email are required' });
}
const requirePassword = parseBooleanInput(requirePasswordInput, true);
// Debug logging
console.log('Download control values:', {
logger.debug('Download control values', {
allow_downloads,
disable_right_click,
watermark_downloads,
watermark_text,
require_password: requirePassword,
types: {
allow_downloads: typeof allow_downloads,
disable_right_click: typeof disable_right_click,
@@ -82,18 +194,21 @@ router.post('/', adminAuth, [
}
});
// Validate password strength
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
let passwordValidation = null;
if (requirePassword) {
passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
}
// Generate unique slug
@@ -111,12 +226,14 @@ router.post('/', adminAuth, [
counter++;
}
// Generate share link
// Generate share link respecting configured format
const shareToken = crypto.randomBytes(16).toString('hex');
const shareLink = `${process.env.FRONTEND_URL}/gallery/${slug}/${shareToken}`;
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password with configurable rounds
const password_hash = await bcrypt.hash(password, getBcryptRounds());
// Hash password with configurable rounds (random placeholder when not required)
const password_hash = requirePassword
? await bcrypt.hash(password, getBcryptRounds())
: 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
@@ -141,13 +258,15 @@ router.post('/', adminAuth, [
event_type,
event_name,
event_date,
host_name,
host_email,
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
host_name: customerName,
host_email: customerEmail,
admin_email,
password_hash,
welcome_message,
color_theme,
share_link: shareLink,
share_link: shareLinkToStore,
share_token: shareToken,
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString(),
allow_user_uploads,
@@ -155,7 +274,8 @@ router.post('/', adminAuth, [
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
watermark_text
watermark_text,
require_password: formatBoolean(requirePassword)
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
@@ -180,7 +300,7 @@ router.post('/', adminAuth, [
// Log activity
await logActivity('event_created',
{ event_type, expires_at },
{ event_type, expires_at, require_password: requirePassword, password_strength: passwordValidation?.score },
eventId,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
@@ -190,14 +310,16 @@ router.post('/', adminAuth, [
await db('email_queue').insert({
event_id: eventId,
recipient_email: host_email,
recipient_email: customerEmail,
email_type: 'gallery_created',
email_data: JSON.stringify({
host_name: host_name,
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName || (customerEmail ? customerEmail.split('@')[0] : null),
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareLink,
gallery_password: password,
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
}),
@@ -211,7 +333,10 @@ router.post('/', adminAuth, [
slug,
event_name,
event_type,
share_link: shareLink,
customer_name: customerName,
customer_email: customerEmail,
require_password: requirePassword,
share_link: shareUrl,
expires_at: expires_at.toISOString(),
created_at: new Date().toISOString()
});
@@ -294,7 +419,7 @@ router.get('/', adminAuth, async (req, res) => {
created_at: event.created_at ? new Date(event.created_at).toISOString() : null,
expires_at: event.expires_at ? new Date(event.expires_at).toISOString() : null,
archived_at: event.archived_at ? new Date(event.archived_at).toISOString() : null
}));
})).map(mapEventForApi);
res.json({
events: eventsWithCounts,
@@ -356,7 +481,7 @@ router.get('/:id', adminAuth, async (req, res) => {
.where('event_id', id)
.countDistinct('ip_address as uniqueVisitors');
res.json({
res.json(mapEventForApi({
...event,
photo_count: parseInt(photoCount) || 0,
total_size: parseInt(totalSize) || 0,
@@ -364,7 +489,7 @@ router.get('/:id', adminAuth, async (req, res) => {
total_downloads: parseInt(totalDownloads) || 0,
unique_visitors: parseInt(uniqueVisitors) || 0,
recent_photos: recentPhotos
});
}));
} catch (error) {
console.error('Error fetching event:', error);
res.status(500).json({ error: 'Failed to fetch event details' });
@@ -380,7 +505,8 @@ router.put('/:id', adminAuth, [
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
body('color_theme').optional({ nullable: true }),
body('allow_user_uploads').optional().isBoolean(),
body('host_name').optional().trim().notEmpty(),
body('customer_name').optional().trim().notEmpty(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('upload_category_id').optional().custom((value) => {
// Accept null, undefined, or integer values
if (value === null || value === undefined) return true;
@@ -396,21 +522,99 @@ router.put('/:id', adminAuth, [
body('allow_downloads').optional().isBoolean(),
body('disable_right_click').optional().isBoolean(),
body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim()
body('watermark_text').optional().trim(),
body('source_mode').optional().isIn(['managed', 'reference']),
body('external_path').optional({ nullable: true }).isString().trim(),
body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
if (value === undefined || value === null || value === '') {
return true;
}
if (typeof value !== 'string' || value.trim().length < 6) {
throw new Error('Password must be at least 6 characters long');
}
return true;
})
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
console.log('Update event validation errors:', JSON.stringify(errors.array(), null, 2));
console.log('Request body:', req.body);
logger.debug('Update event validation errors', { errors: errors.array(), body: req.body });
return res.status(400).json({ errors: errors.array() });
}
const { id } = req.params;
const updates = req.body;
const updates = { ...req.body };
const customerColumnsAvailable = await hasCustomerContactColumns();
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
const nextName = getCustomerNameFromPayload(updates);
if (nextName) {
if (customerColumnsAvailable) {
updates.customer_name = nextName;
} else {
delete updates.customer_name;
}
updates.host_name = nextName;
} else {
delete updates.customer_name;
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
const nextEmail = getCustomerEmailFromPayload(updates);
if (nextEmail) {
if (customerColumnsAvailable) {
updates.customer_email = nextEmail;
} else {
delete updates.customer_email;
}
updates.host_email = nextEmail;
} else {
delete updates.customer_email;
}
}
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
let requirePasswordUpdate;
if (hasRequirePasswordUpdate) {
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
updates.require_password = formatBoolean(requirePasswordUpdate);
}
let newPasswordPlain;
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
if (updates.password === undefined || updates.password === null || updates.password === '') {
delete updates.password;
} else {
newPasswordPlain = updates.password;
delete updates.password;
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'source_mode')) {
updates.source_mode = updates.source_mode === 'reference' ? 'reference' : 'managed';
}
if (Object.prototype.hasOwnProperty.call(updates, 'external_path')) {
const trimmedPath = updates.external_path ? String(updates.external_path).trim() : '';
updates.external_path = trimmedPath || null;
}
if (updates.source_mode === 'managed') {
updates.external_path = null;
}
if (updates.source_mode === 'reference' && (updates.external_path === null || updates.external_path === undefined)) {
return res.status(400).json({ error: 'external_path is required when source_mode is reference' });
}
// Log the update request for debugging
console.log('Update event request:', {
logger.debug('Update event request', {
id,
updates,
color_theme_length: updates.color_theme ? updates.color_theme.length : 0,
@@ -425,6 +629,18 @@ router.put('/:id', adminAuth, [
return res.status(404).json({ error: 'Event not found' });
}
const currentRequirePassword = parseBooleanInput(event.require_password, true);
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
}
if (newPasswordPlain) {
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
}
// Update event
await db('events')
.where('id', id)
@@ -515,13 +731,11 @@ router.delete('/:id', adminAuth, async (req, res) => {
// Provide more specific error messages
if (error.message && error.message.includes('foreign key constraint')) {
res.status(500).json({
error: 'Cannot delete event due to existing references. Please contact support.',
details: error.message
error: 'Cannot delete event due to existing references. Please contact support.'
});
} else {
res.status(500).json({
error: 'Failed to delete event',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
error: 'Failed to delete event'
});
}
}
@@ -598,10 +812,13 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
// Queue email notification if requested
if (sendEmail) {
// For password reset, we'll need to create a template or use a different approach
// For now, let's use the gallery_created template with updated password
await queueEmail(id, event.host_email, 'gallery_created', {
host_name: event.host_email.split('@')[0],
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
await queueEmail(id, recipientEmail, 'gallery_created', {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name,
event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: event.share_link,
@@ -656,8 +873,13 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
// Dates will be formatted by the email processor based on recipient language
// Queue the email
await queueEmail(id, event.host_email, 'gallery_created', {
host_name: event.host_name || event.host_email.split('@')[0],
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
await queueEmail(id, recipientEmail, 'gallery_created', {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name,
event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: event.share_link,
@@ -672,7 +894,7 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
try {
await logActivity('email_resent', {
email_type: 'gallery_created',
recipient: event.host_email,
recipient: recipientEmail,
ip_address: req.ip || '0.0.0.0',
user_agent: req.get('user-agent') || 'Unknown'
}, id, {
@@ -780,7 +1002,7 @@ router.post('/bulk-archive', adminAuth, [
results.failed.push({
id: event.id,
name: event.event_name,
error: error.message
error: 'Failed to archive event. Check server logs for details.'
});
}
}
@@ -806,4 +1028,4 @@ router.post('/bulk-archive', adminAuth, [
}
});
module.exports = router;
module.exports = router;
+147
View File
@@ -0,0 +1,147 @@
const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const { adminAuth } = require('../middleware/auth');
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
const { db, logActivity } = require('../database/db');
const logger = require('../utils/logger');
const router = express.Router();
// GET /api/admin/external-media/list?path=relative/dir
router.get('/list', adminAuth, async (req, res) => {
try {
const relPath = (req.query.path || '').replace(/^\/+/, '');
const result = await list(relPath);
res.json(result);
} catch (error) {
logger.warn('Invalid external media path requested', {
path: req.query.path,
error: error.message
});
res.status(400).json({ error: 'Invalid external media path' });
}
});
// Helper to recursively collect files under a directory, filtered by image extensions
async function walkDir(dir, baseDir) {
const results = [];
const entries = await fs.readdir(dir, { withFileTypes: true });
for (const e of entries) {
if (e.name.startsWith('.')) continue;
const full = path.join(dir, e.name);
if (e.isDirectory()) {
results.push(...await walkDir(full, baseDir));
} else if (e.isFile()) {
const ext = path.extname(e.name).toLowerCase();
if (['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) {
const rel = path.relative(baseDir, full);
results.push({ full, rel, name: e.name });
}
}
}
return results;
}
// POST /api/admin/events/:id/import-external
// Body: { external_path: string, recursive?: boolean, map?: { individual?: string, collages?: string } }
router.post('/events/:id/import-external', adminAuth, async (req, res) => {
try {
const eventId = parseInt(req.params.id);
const { external_path, recursive = true, map = { individual: 'individual', collages: 'collages' } } = req.body || {};
if (!external_path) return res.status(400).json({ error: 'external_path is required' });
// Load event
const event = await db('events').where('id', eventId).first();
if (!event) return res.status(404).json({ error: 'Event not found' });
const baseAbs = resolveExternalPath({ external_path }, '');
// Collect files
const files = recursive ? await walkDir(baseAbs, baseAbs) : (await fs.readdir(baseAbs, { withFileTypes: true }))
.filter(e => e.isFile())
.map(e => ({ full: path.join(baseAbs, e.name), rel: e.name, name: e.name }))
.filter(f => ['.jpg', '.jpeg', '.png', '.webp'].includes(path.extname(f.name).toLowerCase()));
// Prepare file metadata and deduplicate by filename within type (keep largest)
let skipped = 0;
const preparedFiles = [];
for (const f of files) {
try {
const stats = await fs.stat(f.full);
const segs = f.rel.split(path.sep);
let type = 'individual';
if (segs[0] === map.collages) type = 'collage';
if (segs[0] === map.individual) type = 'individual';
preparedFiles.push({ ...f, type, size: stats.size });
} catch (err) {
skipped++;
}
}
const dedupeMap = new Map();
for (const file of preparedFiles) {
const dedupeKey = `${file.type}:${path.basename(file.rel).toLowerCase()}`;
const existing = dedupeMap.get(dedupeKey);
if (!existing || file.size > existing.size) {
if (existing) skipped++;
dedupeMap.set(dedupeKey, file);
} else {
skipped++;
}
}
let imported = 0;
// Insert photos
for (const f of dedupeMap.values()) {
// Infer type by subfolder names
const segs = f.rel.split(path.sep);
let type = 'individual';
if (segs[0] === map.collages) type = 'collage';
if (segs[0] === map.individual) type = 'individual';
try {
// Check if already exists (by external_relpath)
const exists = await db('photos')
.where({ event_id: eventId, external_relpath: f.rel })
.first();
if (exists) { skipped++; continue; }
const stats = await fs.stat(f.full);
const inserted = await db('photos')
.insert({
event_id: eventId,
filename: f.name,
// Keep path as a hint for legacy code but not used for resolution in external mode
path: path.join(event.slug, f.name),
thumbnail_path: null,
type,
size_bytes: stats.size,
source_origin: 'external',
external_relpath: f.rel
})
.returning('id');
imported += (inserted?.length ? 1 : 0);
} catch (e) {
skipped++;
}
}
// Update event fields
await db('events').where('id', eventId).update({ source_mode: 'reference', external_path });
// Queue thumbnail generation lazily by reading thumbnails via ensure endpoint as needed
await logActivity('external_import_completed', { event_id: eventId, imported, skipped, external_path }, eventId, { type: 'admin' });
res.json({ imported, skipped, thumbnailsQueued: 0 });
} catch (error) {
logger.error('External media import failed', {
eventId: req.params.id,
externalPath: req.body?.external_path,
error: error.message
});
res.status(500).json({ error: 'Failed to import external media' });
}
});
module.exports = router;
+6 -6
View File
@@ -60,8 +60,8 @@ router.put('/events/:eventId/feedback-settings',
settings: updatedSettings
}, eventId, {
type: 'admin',
id: req.user.id,
name: req.user.username
id: req.admin.id,
name: req.admin.username
});
res.json(updatedSettings);
@@ -167,7 +167,7 @@ router.put('/feedback/:feedbackId/:action',
return res.status(400).json({ error: 'Invalid action' });
}
await feedbackService.moderateFeedback(feedbackId, action, req.user.id);
await feedbackService.moderateFeedback(feedbackId, action, req.admin.id);
res.json({ success: true });
} catch (error) {
@@ -184,7 +184,7 @@ router.delete('/feedback/:feedbackId',
try {
const { feedbackId } = req.params;
await feedbackService.deleteFeedback(feedbackId, req.user.id);
await feedbackService.deleteFeedback(feedbackId, req.admin.id);
res.json({ success: true });
} catch (error) {
@@ -368,7 +368,7 @@ router.post('/word-filters',
res.json({ success: true });
} catch (error) {
if (error.message === 'Word filter already exists') {
return res.status(409).json({ error: error.message });
return res.status(409).json({ error: 'Word filter already exists' });
}
logger.error('Error adding word filter:', error);
res.status(500).json({ error: 'Failed to add word filter' });
@@ -430,4 +430,4 @@ function convertToCSV(data) {
return [csvHeaders, ...csvRows].join('\n');
}
module.exports = router;
module.exports = router;
+44 -7
View File
@@ -103,14 +103,51 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
// Use database-agnostic date calculation
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const deletedCount = await db('activity_logs')
.whereNotNull('read_at')
.where('created_at', '<', thirtyDaysAgo)
.delete();
let deletedCount = 0;
const client = db?.client?.config?.client;
if (client === 'pg') {
const primaryResult = await db.raw(
`
WITH deleted AS (
DELETE FROM activity_logs
WHERE read_at IS NOT NULL OR created_at < ?
RETURNING id
)
SELECT COUNT(*)::int AS count FROM deleted
`,
[thirtyDaysAgo.toISOString()]
);
deletedCount = primaryResult.rows?.[0]?.count || 0;
if (deletedCount === 0) {
const fallbackResult = await db.raw(
`
WITH deleted AS (
DELETE FROM activity_logs
RETURNING id
)
SELECT COUNT(*)::int AS count FROM deleted
`
);
deletedCount = fallbackResult.rows?.[0]?.count || 0;
}
} else {
deletedCount = await db('activity_logs')
.where(function () {
this.whereNotNull('read_at')
.orWhere('created_at', '<', thirtyDaysAgo);
})
.delete();
if (deletedCount === 0) {
deletedCount = await db('activity_logs').delete();
}
}
res.json({
message: 'Old notifications cleared',
message: deletedCount > 0 ? 'Old notifications cleared' : 'No notifications to clear',
deletedCount
});
} catch (error) {
@@ -119,4 +156,4 @@ router.delete('/clear-old', adminAuth, async (req, res) => {
}
});
module.exports = router;
module.exports = router;
+234 -34
View File
@@ -8,6 +8,9 @@ const { generateThumbnail, ensureThumbnail } = require('../services/imageProcess
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
const { processUploadedPhotos } = require('../services/photoProcessor');
const chunkedUpload = require('../services/chunkedUploadService');
const router = express.Router();
// Get storage path from environment or default
@@ -47,8 +50,8 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
const upload = multer({
storage: storage,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit per file
files: 500, // Maximum 500 files
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
@@ -56,13 +59,16 @@ const upload = multer({
headerPairs: 2000 // Maximum number of header key-value pairs
},
fileFilter: (req, file, cb) => {
// Accept images only with proper validation
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
// Accept images and videos with proper validation
const allowedMimeTypes = [
'image/jpeg', 'image/png', 'image/webp',
'video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'
];
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
return cb(null, true);
} else {
cb(new Error('Only JPEG, PNG and WebP images are allowed'));
cb(new Error('Only JPEG, PNG, WebP images and MP4, WebM, MOV, AVI videos are allowed'));
}
},
// Add abort on limit to stop processing when limits are exceeded
@@ -73,8 +79,11 @@ const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
// Create content validator middleware
const validateUploadContent = createFileUploadValidator({
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
maxFileSize: 50 * 1024 * 1024,
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
});
@@ -99,17 +108,25 @@ const uploadTimeout = (timeout = 300000) => { // 5 minutes default
};
// Upload photos for an event
// Increased limit to 500 files, but recommend chunked uploads for better performance
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, next) => { // 10 minute timeout
upload.array('photos', 500)(req, res, (err) => {
// Max file count is configurable via general settings
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, res, next) => { // 10 minute timeout
let maxFilesPerUpload;
try {
maxFilesPerUpload = await getMaxFilesPerUpload();
} catch (error) {
console.error('Failed to resolve max files per upload:', error);
return res.status(500).json({ error: 'Unable to determine upload limits' });
}
upload.array('photos', maxFilesPerUpload)(req, res, (err) => {
if (err) {
console.error('Multer error:', err);
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
return res.status(400).json({ error: 'File too large. Maximum size is 10GB per file.' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' });
if (err.code === 'LIMIT_FILE_COUNT' || err.code === 'LIMIT_UNEXPECTED_FILE') {
return res.status(400).json({ error: `Too many files. Maximum ${maxFilesPerUpload} files per upload.` });
}
return res.status(400).json({ error: `Upload error: ${err.message}` });
}
@@ -464,21 +481,42 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
try {
const { eventId, photoId } = req.params;
const { category_id } = req.body;
// Verify photo belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: eventId })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Prepare update data
const updateData = {};
// 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') {
updateData.type = category_id;
updateData.category_id = null; // Clear legacy category_id
} else if (category_id === null || category_id === undefined) {
// Explicitly clear category
updateData.category_id = null;
} else {
// Handle numeric category IDs from photo_categories table
const numericCategoryId = parseInt(category_id, 10);
if (!isNaN(numericCategoryId)) {
updateData.category_id = numericCategoryId;
} else {
updateData.category_id = null;
}
}
// Update photo
await db('photos')
.where({ id: photoId })
.update({ category_id: category_id || null });
.where({ id: photoId, event_id: eventId })
.update(updateData);
res.json({ message: 'Photo updated successfully' });
} catch (error) {
console.error('Error updating photo:', error);
@@ -571,21 +609,40 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
.count('id as count')
.first();
if (photoCount.count !== photoIds.length) {
if (parseInt(photoCount.count) !== photoIds.length) {
return res.status(400).json({ error: 'Some photos do not belong to this event' });
}
// Update photos
const updateData = {};
// Prepare update data
const updateData = {
updated_at: new Date()
};
if (updates.category_id !== undefined) {
updateData.category_id = updates.category_id || null;
// Handle type-based categories ('individual' or 'collage')
// These are string values that map to the photo.type field
if (updates.category_id === 'individual' || updates.category_id === 'collage') {
updateData.type = updates.category_id;
updateData.category_id = null; // Clear legacy category_id
} else if (updates.category_id === null) {
// Explicitly clear category
updateData.category_id = null;
} else {
// Handle numeric category IDs from photo_categories table
const numericCategoryId = parseInt(updates.category_id, 10);
if (!isNaN(numericCategoryId)) {
updateData.category_id = numericCategoryId;
} else {
updateData.category_id = null;
}
}
}
await db('photos')
.whereIn('id', photoIds)
.where('event_id', eventId)
.update(updateData);
res.json({ message: `${photoIds.length} photos updated successfully` });
} catch (error) {
console.error('Error bulk updating photos:', error);
@@ -606,8 +663,9 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) =>
return res.status(404).json({ error: 'Photo not found' });
}
const storagePath = getStoragePath();
const filePath = path.join(storagePath, 'events/active', photo.path);
const { resolvePhotoFilePath } = require('../services/photoResolver');
const event = await db('events').where('id', eventId).first();
const filePath = resolvePhotoFilePath(event, photo);
// Check if file exists
try {
@@ -684,8 +742,10 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
url: `/admin/events/${eventId}/photo/${photo.id}`,
thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : 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
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
type: photo.type,
category_id: photo.type,
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
@@ -719,8 +779,9 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
return res.status(404).json({ error: 'Photo not found' });
}
const storagePath = getStoragePath();
const filePath = path.join(storagePath, 'events/active', photo.path);
const { resolvePhotoFilePath } = require('../services/photoResolver');
const event = await db('events').where('id', eventId).first();
const filePath = resolvePhotoFilePath(event, photo);
// Check if file exists
try {
@@ -798,8 +859,147 @@ router.get('/:eventId/debug', adminAuth, async (req, res) => {
storagePath: getStoragePath()
});
} catch (error) {
res.status(500).json({ error: error.message });
console.error('Error fetching admin photo debug data:', error);
res.status(500).json({ error: 'Failed to fetch photo debug data' });
}
});
module.exports = router;
// ============================================
// CHUNKED UPLOAD ENDPOINTS
// For large file uploads (videos up to 10GB)
// ============================================
// Initialize a chunked upload
router.post('/:eventId/chunked-upload/init', adminAuth, async (req, res) => {
try {
const { eventId } = req.params;
const { filename, fileSize, mimeType, totalChunks } = req.body;
// Validate event exists
const event = await db('events').where({ id: eventId }).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// Validate required fields
if (!filename || !fileSize || !mimeType) {
return res.status(400).json({ error: 'Missing required fields: filename, fileSize, mimeType' });
}
// Validate file size (max 10GB)
const maxSize = 10 * 1024 * 1024 * 1024;
if (fileSize > maxSize) {
return res.status(400).json({ error: `File too large. Maximum size is 10GB.` });
}
const result = await chunkedUpload.initializeUpload({
filename,
fileSize,
mimeType,
eventId: parseInt(eventId),
totalChunks
});
res.json(result);
} catch (error) {
console.error('Error initializing chunked upload:', error);
res.status(500).json({ error: 'Failed to initialize upload' });
}
});
// Upload a chunk
router.post('/:eventId/chunked-upload/:uploadId/chunk/:chunkIndex', adminAuth, async (req, res) => {
try {
const { uploadId, chunkIndex } = req.params;
// Get chunk data from request body
const chunks = [];
for await (const chunk of req) {
chunks.push(chunk);
}
const chunkData = Buffer.concat(chunks);
const result = await chunkedUpload.uploadChunk(uploadId, parseInt(chunkIndex), chunkData);
res.json(result);
} catch (error) {
console.error('Error uploading chunk:', error);
res.status(500).json({ error: error.message || 'Failed to upload chunk' });
}
});
// Complete chunked upload and process the file
router.post('/:eventId/chunked-upload/:uploadId/complete', adminAuth, async (req, res) => {
try {
const { eventId, uploadId } = req.params;
const { category_id } = req.body;
// Complete the chunked upload (merge chunks)
const mergedFile = await chunkedUpload.completeUpload(uploadId);
// Process the merged file as a regular upload
const fileObj = {
originalname: mergedFile.filename,
mimetype: mergedFile.mimeType,
size: mergedFile.size,
path: mergedFile.path
};
const uploadedPhotos = await processUploadedPhotos(
[fileObj],
parseInt(eventId),
'admin',
category_id || null
);
// Clean up temp directory
try {
await fs.rm(mergedFile.tempDir, { recursive: true, force: true });
} catch (cleanupErr) {
console.warn('Failed to clean up temp directory:', cleanupErr.message);
}
res.json({
success: true,
uploaded: uploadedPhotos.length,
photos: uploadedPhotos
});
} catch (error) {
console.error('Error completing chunked upload:', error);
res.status(500).json({ error: error.message || 'Failed to complete upload' });
}
});
// Get upload status
router.get('/:eventId/chunked-upload/:uploadId/status', adminAuth, async (req, res) => {
try {
const { uploadId } = req.params;
const status = chunkedUpload.getUploadStatus(uploadId);
if (!status) {
return res.status(404).json({ error: 'Upload not found or expired' });
}
res.json(status);
} catch (error) {
console.error('Error getting upload status:', error);
res.status(500).json({ error: 'Failed to get upload status' });
}
});
// Abort chunked upload
router.delete('/:eventId/chunked-upload/:uploadId', adminAuth, async (req, res) => {
try {
const { uploadId } = req.params;
await chunkedUpload.abortUpload(uploadId);
res.json({ success: true, message: 'Upload aborted' });
} catch (error) {
console.error('Error aborting upload:', error);
res.status(500).json({ error: 'Failed to abort upload' });
}
});
module.exports = router;
+3 -3
View File
@@ -82,7 +82,7 @@ router.post('/validate', [
logger.error('Restore validation failed:', error);
res.status(400).json({
success: false,
error: error.message,
error: 'Restore validation failed',
logs: restoreService.restoreLog
});
}
@@ -162,7 +162,7 @@ router.post('/start', [
logger.error('Failed to start restore:', error);
res.status(500).json({
success: false,
error: error.message
error: 'Failed to start restore operation'
});
}
});
@@ -458,4 +458,4 @@ async function getBackupConfig() {
return config;
}
module.exports = router;
module.exports = router;
+359 -11
View File
@@ -8,12 +8,27 @@ const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { clearMaintenanceCache } = require('../middleware/maintenance');
const { clearSettingsCache } = require('../services/rateLimitService');
const {
DEFAULT_PUBLIC_SITE_HTML,
DEFAULT_PUBLIC_SITE_CSS,
} = require('../constants/publicSiteDefaults');
const {
clearPublicSiteCache,
getDefaultPublicSitePayload,
getRawPublicSiteSettings,
} = require('../services/publicSiteService');
const { sanitizeCss } = require('../utils/cssSanitizer');
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 getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Configure multer for logo uploads
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../storage/uploads/logos');
const uploadDir = path.join(getStoragePath(), 'uploads/logos');
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
},
@@ -43,7 +58,7 @@ const upload = multer({
// Configure multer for favicon uploads
const faviconStorage = multer.diskStorage({
destination: async (req, file, cb) => {
const uploadDir = path.join(__dirname, '../../storage/uploads/favicons');
const uploadDir = path.join(getStoragePath(), 'uploads/favicons');
await fs.mkdir(uploadDir, { recursive: true });
cb(null, uploadDir);
},
@@ -170,7 +185,14 @@ router.put('/branding', adminAuth, async (req, res) => {
watermark_size,
favicon_url,
logo_url,
watermark_logo_url
watermark_logo_url,
logo_size,
logo_max_height,
logo_position,
logo_display_header,
logo_display_hero,
logo_display_mode,
hide_powered_by
} = req.body;
const brandingSettings = {
@@ -184,7 +206,14 @@ router.put('/branding', adminAuth, async (req, res) => {
watermark_size,
favicon_url,
logo_url,
watermark_logo_url
watermark_logo_url,
logo_size,
logo_max_height,
logo_position,
logo_display_header,
logo_display_hero,
logo_display_mode,
hide_powered_by
};
// Handle favicon deletion if empty string or null is provided
@@ -206,7 +235,8 @@ router.put('/branding', adminAuth, async (req, res) => {
if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) {
// Delete the file from filesystem
const faviconPath = path.join(__dirname, '..', '..', 'storage', currentFaviconUrl.replace('/uploads/', ''));
const relativePath = currentFaviconUrl.replace(/^\//, '');
const faviconPath = path.join(getStoragePath(), relativePath);
try {
await fs.unlink(faviconPath);
console.log('Deleted favicon file:', faviconPath);
@@ -236,7 +266,8 @@ router.put('/branding', adminAuth, async (req, res) => {
if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) {
// Delete the file from filesystem
const logoPath = path.join(__dirname, '..', '..', 'storage', currentLogoUrl.replace('/uploads/', ''));
const relativePath = currentLogoUrl.replace(/^\//, '');
const logoPath = path.join(getStoragePath(), relativePath);
try {
await fs.unlink(logoPath);
console.log('Deleted logo file:', logoPath);
@@ -272,6 +303,8 @@ router.put('/branding', adminAuth, async (req, res) => {
metadata: JSON.stringify({ company_name })
});
clearPublicSiteCache();
res.json({ message: 'Branding settings updated successfully' });
} catch (error) {
console.error('Branding update error:', error);
@@ -431,6 +464,8 @@ router.put('/theme', adminAuth, async (req, res) => {
metadata: JSON.stringify({ theme_name: themeSettings.name || 'custom' })
});
clearPublicSiteCache();
res.json({ message: 'Theme settings updated successfully' });
} catch (error) {
console.error('Theme update error:', error);
@@ -441,7 +476,54 @@ router.put('/theme', adminAuth, async (req, res) => {
// Update general settings
router.put('/general', adminAuth, async (req, res) => {
try {
const settings = req.body;
const settings = { ...req.body };
let uploadLimitTouched = false;
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
if (Object.prototype.hasOwnProperty.call(settings, 'general_max_files_per_upload')) {
uploadLimitTouched = true;
const rawValue = Number(settings.general_max_files_per_upload);
const normalizedValue = Number.isFinite(rawValue) ? Math.floor(rawValue) : NaN;
if (!Number.isInteger(normalizedValue) || normalizedValue < 1 || normalizedValue > MAX_ALLOWED_FILES_PER_UPLOAD) {
return res.status(400).json({
error: `general_max_files_per_upload must be an integer between 1 and ${MAX_ALLOWED_FILES_PER_UPLOAD}`
});
}
settings.general_max_files_per_upload = normalizedValue;
}
if (publicSiteKeysTouched) {
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_custom_css')) {
settings.general_public_site_custom_css = sanitizeCss(settings.general_public_site_custom_css || '');
}
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_html') && typeof settings.general_public_site_html === 'string') {
settings.general_public_site_html = settings.general_public_site_html.trim();
}
if (Object.prototype.hasOwnProperty.call(settings, 'general_public_site_enabled')) {
settings.general_public_site_enabled = formatBoolean(settings.general_public_site_enabled);
}
const enableToggle = settings.general_public_site_enabled;
if (enableToggle === true) {
let htmlValue = settings.general_public_site_html;
if (htmlValue === undefined) {
const currentSettings = await getRawPublicSiteSettings();
htmlValue = currentSettings.general_public_site_html;
}
if (!htmlValue || !String(htmlValue).trim()) {
return res.status(400).json({
error: 'Public site HTML must be provided before enabling the public landing page.'
});
}
}
}
// Update or insert each setting
for (const [key, value] of Object.entries(settings)) {
@@ -464,6 +546,16 @@ router.put('/general', adminAuth, async (req, res) => {
clearMaintenanceCache();
}
if (publicSiteKeysTouched) {
clearPublicSiteCache();
}
if (uploadLimitTouched) {
clearMaxFilesPerUploadCache();
}
if (Object.prototype.hasOwnProperty.call(settings, 'general_short_gallery_urls')) {
clearShareLinkSettingsCache();
}
// Log activity
await db('activity_logs').insert({
activity_type: 'general_settings_updated',
@@ -501,6 +593,8 @@ router.put('/security', adminAuth, async (req, res) => {
});
}
resetSecurityConfigCache();
// Log activity
await db('activity_logs').insert({
activity_type: 'security_settings_updated',
@@ -581,7 +675,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
for (const archive of archives) {
if (archive.archive_path) {
try {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const storagePath = getStoragePath();
const fullArchivePath = path.join(storagePath, archive.archive_path);
const stats = await fs.stat(fullArchivePath);
archiveStorage += stats.size;
@@ -591,11 +685,190 @@ router.get('/storage/info', adminAuth, async (req, res) => {
}
}
const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback
const storagePath = getStoragePath();
let diskStats = null;
let rawDiskTotal = null;
let rawDiskFree = null;
let rawDiskAvailable = null;
try {
diskStats = await fs.statfs(storagePath);
rawDiskTotal = Number(diskStats.bsize) * Number(diskStats.blocks);
rawDiskFree = Number(diskStats.bsize) * Number(diskStats.bfree);
rawDiskAvailable = Number(diskStats.bsize) * Number(diskStats.bavail);
} catch (diskError) {
console.error('Disk stats error:', diskError.message);
}
const clampDiskValue = (value) => {
if (!Number.isFinite(value) || value <= 0) {
return null;
}
// Treat unusually large virtualised values as unreliable (>50TB)
const MAX_REASONABLE_BYTES = 50 * 1024 * 1024 * 1024 * 1024;
if (value > MAX_REASONABLE_BYTES) {
return null;
}
return value;
};
let diskTotal = null;
let diskFree = null;
let diskAvailable = null;
if (diskStats) {
diskTotal = clampDiskValue(rawDiskTotal);
diskFree = clampDiskValue(rawDiskFree);
diskAvailable = clampDiskValue(rawDiskAvailable);
if (diskTotal && diskAvailable && diskAvailable > diskTotal) {
diskAvailable = null;
}
if (diskTotal && diskFree && diskFree > diskTotal) {
diskFree = null;
}
}
const totalUsed = totalStorage?.total || 0;
const parseBytesValue = (value) => {
const numeric = Number(value);
if (!Number.isFinite(numeric) || numeric <= 0) {
return null;
}
return Math.floor(numeric);
};
const parseEnvOverride = (bytesVar, gbVar) => {
if (process.env[bytesVar]) {
return parseBytesValue(process.env[bytesVar]);
}
if (process.env[gbVar]) {
const value = parseBytesValue(process.env[gbVar]);
return value ? value * 1024 * 1024 * 1024 : null;
}
return null;
};
let configuredSoftLimit = null;
let capacityOverrideDb = null;
let availableOverrideDb = null;
try {
const storageSettings = await db('app_settings')
.whereIn('setting_key', [
'general_storage_soft_limit_bytes',
'general_storage_capacity_override_bytes',
'general_storage_available_override_bytes'
])
.select('setting_key', 'setting_value');
storageSettings.forEach((setting) => {
let parsedValue = null;
if (setting.setting_value) {
try {
parsedValue = JSON.parse(setting.setting_value);
} catch (error) {
parsedValue = setting.setting_value;
}
}
switch (setting.setting_key) {
case 'general_storage_soft_limit_bytes':
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
configuredSoftLimit = parsedValue;
}
break;
case 'general_storage_capacity_override_bytes':
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
capacityOverrideDb = parsedValue;
}
break;
case 'general_storage_available_override_bytes':
if (typeof parsedValue === 'number' && !Number.isNaN(parsedValue)) {
availableOverrideDb = parsedValue;
}
break;
default:
break;
}
});
} catch (error) {
console.error('Storage settings read error:', error.message);
}
const capacityOverrideEnv = parseEnvOverride('STORAGE_CAPACITY_OVERRIDE_BYTES', 'STORAGE_CAPACITY_OVERRIDE_GB');
const availableOverrideEnv = parseEnvOverride('STORAGE_AVAILABLE_OVERRIDE_BYTES', 'STORAGE_AVAILABLE_OVERRIDE_GB');
let capacityOverrideBytes = null;
let availableOverrideBytes = null;
let overrideSource = null;
if (capacityOverrideEnv != null || availableOverrideEnv != null) {
capacityOverrideBytes = capacityOverrideEnv;
availableOverrideBytes = availableOverrideEnv;
overrideSource = 'env';
} else if (capacityOverrideDb != null || availableOverrideDb != null) {
capacityOverrideBytes = capacityOverrideDb;
availableOverrideBytes = availableOverrideDb;
overrideSource = 'settings';
}
if (capacityOverrideBytes != null) {
diskTotal = capacityOverrideBytes;
if (availableOverrideBytes == null) {
diskAvailable = Math.max(capacityOverrideBytes - totalUsed, 0);
} else {
diskAvailable = Math.min(Math.max(availableOverrideBytes, 0), capacityOverrideBytes);
}
diskFree = diskAvailable;
} else if (availableOverrideBytes != null) {
diskAvailable = Math.max(availableOverrideBytes, 0);
diskFree = diskAvailable;
}
let recommendedSoftLimit = null;
if (diskTotal && diskAvailable) {
const projected = totalUsed + Math.floor(diskAvailable * 0.8);
recommendedSoftLimit = Math.min(diskTotal, Math.max(projected, Math.floor(diskTotal * 0.5)));
} else if (diskTotal) {
recommendedSoftLimit = Math.floor(diskTotal * 0.8);
} else if (diskAvailable) {
recommendedSoftLimit = Math.max(totalUsed, totalUsed + Math.floor(diskAvailable * 0.8));
}
if (recommendedSoftLimit && totalUsed > 0 && recommendedSoftLimit < totalUsed) {
recommendedSoftLimit = totalUsed;
}
const fallbackSoftLimit = recommendedSoftLimit || diskTotal || DEFAULT_SOFT_LIMIT_BYTES;
if (!recommendedSoftLimit && fallbackSoftLimit) {
recommendedSoftLimit = fallbackSoftLimit;
}
const effectiveSoftLimit = configuredSoftLimit || fallbackSoftLimit || DEFAULT_SOFT_LIMIT_BYTES;
const diskMetricsReliable = Boolean(diskTotal);
res.json({
total_used: totalStorage.total || 0,
total_used: totalUsed,
archive_storage: archiveStorage,
storage_by_event: storageByEvent,
storage_limit: 10 * 1024 * 1024 * 1024 // 10GB default
storage_limit: effectiveSoftLimit,
storage_soft_limit: effectiveSoftLimit,
configured_soft_limit: configuredSoftLimit,
recommended_soft_limit: recommendedSoftLimit,
soft_limit_configured: Boolean(configuredSoftLimit),
disk_total: diskTotal,
disk_free: diskFree,
disk_available: diskAvailable,
disk_total_raw: rawDiskTotal,
disk_free_raw: rawDiskFree,
disk_available_raw: rawDiskAvailable,
disk_metrics_reliable: diskMetricsReliable,
disk_override_source: overrideSource
});
} catch (error) {
console.error('Storage info error:', error);
@@ -705,4 +978,79 @@ router.put('/security/rate-limit', adminAuth, [
}
});
module.exports = router;
// Get default public site template
router.get('/public-site/default', adminAuth, async (req, res) => {
try {
const defaults = await getDefaultPublicSitePayload();
res.json({
enabled: false,
html: DEFAULT_PUBLIC_SITE_HTML.trim(),
css: '',
baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(),
branding: defaults.branding,
meta: {
title: defaults.title,
}
});
} catch (error) {
console.error('Failed to load public site defaults:', error);
res.status(500).json({ error: 'Failed to load defaults' });
}
});
// Reset public site template to defaults
router.post('/public-site/reset', adminAuth, async (req, res) => {
try {
const entries = [
{
key: 'general_public_site_html',
value: DEFAULT_PUBLIC_SITE_HTML.trim()
},
{
key: 'general_public_site_custom_css',
value: ''
}
];
for (const { key, value } of entries) {
await db('app_settings')
.insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'general',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: JSON.stringify(value),
updated_at: new Date()
});
}
clearPublicSiteCache();
const defaults = await getDefaultPublicSitePayload();
await logActivity('public_site_reset_to_default',
{
template_length: DEFAULT_PUBLIC_SITE_HTML.length,
},
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
message: 'Public site template reset to defaults',
html: DEFAULT_PUBLIC_SITE_HTML.trim(),
css: '',
baseCss: DEFAULT_PUBLIC_SITE_CSS.trim(),
branding: defaults.branding
});
} catch (error) {
console.error('Failed to reset public site template:', error);
res.status(500).json({ error: 'Failed to reset template' });
}
});
module.exports = router;
+7 -2
View File
@@ -5,6 +5,7 @@ const fs = require('fs').promises;
const path = require('path');
const os = require('os');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
const router = express.Router();
// Get system version
@@ -208,10 +209,14 @@ router.get('/database', adminAuth, async (req, res) => {
});
} catch (error) {
// Table might not exist
logger.warn('Failed to retrieve table info', {
table,
error: error.message
});
tableInfo.push({
name: table,
rows: 0,
error: error.message
error: 'Unable to retrieve table details'
});
}
}
@@ -226,4 +231,4 @@ router.get('/database', adminAuth, async (req, res) => {
}
});
module.exports = router;
module.exports = router;
+59 -41
View File
@@ -12,13 +12,14 @@ const {
checkSuspiciousActivity,
getGenericAuthError
} = require('../utils/authSecurity');
const {
const {
validatePasswordInContext,
getBcryptRounds,
logPasswordValidationFailure
} = require('../utils/passwordValidation');
const { endSession } = require('../middleware/sessionTimeout');
const logger = require('../utils/logger');
const { getClientIp } = require('../utils/requestIp');
const router = express.Router();
// Admin login with enhanced security
@@ -33,7 +34,7 @@ router.post('/admin/login', [
}
const { username, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Check account lockout first
@@ -175,7 +176,7 @@ router.post('/admin/change-password', [
logger.info('Admin password changed', {
userId: adminId,
username: admin.username,
ip: req.ip
ip: ipAddress
});
res.json({
@@ -220,7 +221,7 @@ router.post('/logout', async (req, res) => {
// Gallery password verification with enhanced security
router.post('/gallery/verify', [
body('slug').notEmpty().trim(),
body('password').notEmpty()
body('password').optional().isString()
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -229,56 +230,72 @@ router.post('/gallery/verify', [
}
const { slug, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Check gallery-specific lockout
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
// Verify reCAPTCHA
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
const requiresPassword = !(event && (event.require_password === false || event.require_password === 0 || event.require_password === '0'));
if (requiresPassword) {
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress);
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
}
if (!event) {
// Don't reveal if gallery exists
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
if (requiresPassword) {
if (!password) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_fail'
});
return res.status(401).json({ error: 'Invalid gallery or password' });
}
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_fail'
action: 'login_success'
});
} else {
logger.info('Public gallery access granted without password', { slug, ipAddress });
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
});
return res.status(401).json({ error: 'Invalid gallery or password' });
}
// Successful access
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
// Log successful access
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
});
// Generate session token with additional security info
const token = jwt.sign({
eventId: event.id,
@@ -302,7 +319,8 @@ router.post('/gallery/verify', [
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id
upload_category_id: event.upload_category_id,
require_password: requiresPassword
}
});
} catch (error) {
@@ -372,4 +390,4 @@ router.post('/password-strength', [
}
});
module.exports = router;
module.exports = router;
+182 -53
View File
@@ -14,6 +14,16 @@ const {
} = require('../utils/authSecurity');
const { endSession } = require('../middleware/sessionTimeout');
const logger = require('../utils/logger');
const {
setAdminAuthCookie,
clearAdminAuthCookie,
setGalleryAuthCookies,
clearGalleryAuthCookies,
getAdminTokenFromRequest,
getGalleryTokenFromRequest,
} = require('../utils/tokenUtils');
const { getEventShareToken, resolveShareIdentifier } = require('../services/shareLinkService');
const { getClientIp } = require('../utils/requestIp');
const router = express.Router();
// Admin login with enhanced security
@@ -28,7 +38,7 @@ router.post('/admin/login', [
}
const { username, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Check account lockout first
@@ -91,6 +101,8 @@ router.post('/admin/login', [
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setAdminAuthCookie(res, token);
res.json({
token,
@@ -110,13 +122,14 @@ router.post('/admin/login', [
// Logout endpoint
router.post('/logout', async (req, res) => {
try {
const token = req.headers.authorization?.split(' ')[1];
const adminToken = getAdminTokenFromRequest(req);
const galleryToken = getGalleryTokenFromRequest(req);
const token = adminToken || galleryToken;
if (token) {
// End the session
endSession(token);
// Log the logout
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
logger.info('User logged out', {
@@ -124,11 +137,23 @@ router.post('/logout', async (req, res) => {
username: decoded.username,
type: decoded.type
});
if (decoded.type === 'admin') {
clearAdminAuthCookie(res);
} else if (decoded.type === 'gallery') {
clearGalleryAuthCookies(res, decoded.eventSlug);
}
} catch (err) {
// Token might be invalid, but still process logout
// Token might be invalid, but still process logout and clear cookies
clearAdminAuthCookie(res);
clearGalleryAuthCookies(res);
}
} else {
// No token found, but ensure cookies are cleared
clearAdminAuthCookie(res);
clearGalleryAuthCookies(res);
}
res.json({ message: 'Logged out successfully' });
} catch (error) {
logger.error('Logout error:', error);
@@ -139,7 +164,7 @@ router.post('/logout', async (req, res) => {
// Gallery password verification with enhanced security
router.post('/gallery/verify', [
body('slug').notEmpty().trim(),
body('password').notEmpty()
body('password').optional().isString()
], async (req, res) => {
try {
const errors = validationResult(req);
@@ -148,57 +173,70 @@ router.post('/gallery/verify', [
}
const { slug, password, recaptchaToken } = req.body;
const ipAddress = req.ip || req.connection.remoteAddress;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
// Check gallery-specific lockout
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`);
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
// Verify reCAPTCHA
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
const event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
// Don't reveal if gallery exists
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
if (requiresPassword) {
const lockoutStatus = await checkAccountLockout(`gallery:${slug}`, ipAddress);
if (lockoutStatus.isLocked) {
logger.warn('Gallery access attempt on locked gallery', { slug, ipAddress });
return res.status(423).json({
error: 'Too many failed attempts. Please try again later.',
retryAfter: lockoutStatus.remainingTime
});
}
const recaptchaValid = await verifyRecaptcha(recaptchaToken);
if (!recaptchaValid) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
if (!password) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
return res.status(401).json({ error: 'Invalid gallery or password' });
}
const validPassword = await bcrypt.compare(password, event.password_hash);
if (!validPassword) {
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_fail'
});
return res.status(401).json({ error: 'Invalid gallery or password' });
}
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_fail'
action: 'login_success'
});
} else {
logger.info('Public gallery access granted without password', { slug, ipAddress });
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
});
return res.status(401).json({ error: 'Invalid gallery or password' });
}
// Successful access
await trackSuccessfulLogin(`gallery:${slug}`, ipAddress, userAgent);
// Log successful access
await db('access_logs').insert({
event_id: event.id,
ip_address: ipAddress,
user_agent: userAgent,
action: 'login_success'
});
// Generate session token with additional security info
const token = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
@@ -209,6 +247,8 @@ router.post('/gallery/verify', [
expiresIn: '24h',
issuer: 'picpeak-auth'
});
setGalleryAuthCookies(res, token, event.slug);
res.json({
token,
@@ -221,7 +261,8 @@ router.post('/gallery/verify', [
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id
upload_category_id: event.upload_category_id,
require_password: requiresPassword
}
});
} catch (error) {
@@ -230,15 +271,101 @@ router.post('/gallery/verify', [
}
});
// Share link authentication (token-based)
router.post('/gallery/share-login', [
body('slug').notEmpty().trim(),
body('token').notEmpty()
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { slug, token } = req.body;
const ipAddress = getClientIp(req);
const userAgent = req.headers['user-agent'] || '';
let event = await db('events')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.first();
if (!event) {
const resolved = await resolveShareIdentifier(slug);
if (resolved?.event) {
event = resolved.event;
}
}
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
const expectedToken = getEventShareToken(event);
if (!expectedToken || token !== expectedToken) {
return res.status(401).json({ error: 'Invalid or expired share link' });
}
const jwtToken = jwt.sign({
eventId: event.id,
eventSlug: event.slug,
type: 'gallery',
ip: ipAddress,
loginTime: Date.now()
}, process.env.JWT_SECRET, {
expiresIn: '24h',
issuer: 'picpeak-auth'
});
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
setGalleryAuthCookies(res, jwtToken, event.slug);
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({
token: jwtToken,
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: requiresPassword
}
});
} catch (error) {
logger.error('Share link authentication error:', error);
res.status(500).json({ error: 'Share link login failed' });
}
});
// Gallery logout to clear cookies
router.post('/gallery/logout', async (req, res) => {
try {
const { slug } = req.body || {};
clearGalleryAuthCookies(res, slug);
res.json({ message: 'Logged out successfully' });
} catch (error) {
logger.error('Gallery logout error:', error);
res.status(500).json({ error: 'Logout failed' });
}
});
// Get current session info
router.get('/session', async (req, res) => {
try {
const token = req.headers.authorization?.split(' ')[1];
const { slug } = req.query;
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
@@ -250,7 +377,9 @@ router.get('/session', async (req, res) => {
valid: true,
type: decoded.type,
expiresIn: Math.floor(remainingTime),
user: decoded.username || decoded.eventSlug
user: decoded.username || decoded.eventSlug,
eventSlug: decoded.eventSlug,
adminUsername: decoded.username
});
} catch (err) {
res.json({
@@ -263,4 +392,4 @@ router.get('/session', async (req, res) => {
}
});
module.exports = router;
module.exports = router;
+221 -25
View File
@@ -4,19 +4,107 @@ const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const fs = require('fs').promises;
const path = require('path');
const router = express.Router();
const { buildShareLinkVariants } = require('../services/shareLinkService');
const parseBooleanInput = (value, defaultValue = true) => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (['false', '0', 'no', 'off'].includes(normalized)) {
return false;
}
if (['true', '1', 'yes', 'on'].includes(normalized)) {
return true;
}
}
return defaultValue;
};
const getCustomerNameFromPayload = (payload = {}) => {
if (typeof payload.customer_name === 'string') {
const trimmed = payload.customer_name.trim();
return trimmed || null;
}
return null;
};
const getCustomerEmailFromPayload = (payload = {}) => {
if (typeof payload.customer_email === 'string') {
const trimmed = payload.customer_email.trim();
return trimmed || null;
}
return null;
};
const mapEventForApi = (event) => {
if (!event || typeof event !== 'object') {
return event;
}
const {
host_name,
host_email,
customer_name,
customer_email,
...rest
} = event;
return {
...rest,
customer_name: customer_name ?? host_name ?? null,
customer_email: customer_email ?? host_email ?? null
};
};
let customerColumnCache = null;
const hasCustomerContactColumns = async () => {
if (customerColumnCache === true) {
return true;
}
try {
const hasColumn = await db.schema.hasColumn('events', 'customer_email');
if (hasColumn) {
customerColumnCache = true;
}
return hasColumn;
} catch (error) {
return false;
}
};
// Create new event
router.post('/', adminAuth, [
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
body('event_name').notEmpty(),
body('event_date').isDate(),
body('host_email').isEmail(),
body('customer_name').notEmpty().trim(),
body('customer_email').isEmail().normalizeEmail(),
body('admin_email').isEmail(),
body('password').isLength({ min: 6 }),
body('require_password').optional().isBoolean(),
body('password').optional().isString().custom((value, { req }) => {
const requirePassword = parseBooleanInput(req.body.require_password, true);
if (!requirePassword) {
return true;
}
if (typeof value !== 'string' || value.trim().length < 6) {
throw new Error('Password must be at least 6 characters long');
}
return true;
}),
body('expiration_days').isInt({ min: 1, max: 365 }).optional()
], async (req, res) => {
try {
@@ -29,13 +117,39 @@ router.post('/', adminAuth, [
event_type,
event_name,
event_date,
host_email,
admin_email,
password,
require_password: requirePasswordInput = true,
welcome_message,
color_theme,
expiration_days = 30
} = req.body;
const customerEmail = getCustomerEmailFromPayload(req.body);
const customerName = getCustomerNameFromPayload(req.body);
if (!customerName || !customerEmail) {
return res.status(400).json({ error: 'customer_name and customer_email are required' });
}
const customerColumnsAvailable = await hasCustomerContactColumns();
const requirePassword = parseBooleanInput(requirePasswordInput, true);
if (requirePassword) {
const passwordValidation = await validatePasswordInContext(password, 'gallery', {
eventName: event_name
});
if (!passwordValidation.valid) {
return res.status(400).json({
error: 'Password does not meet security requirements',
details: passwordValidation.errors,
score: passwordValidation.score,
feedback: passwordValidation.feedback
});
}
}
// Generate unique slug
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
@@ -47,12 +161,14 @@ router.post('/', adminAuth, [
counter++;
}
// Generate share link (just slug/token, not full URL)
// Generate share link variants (auto-detects short URL preference)
const shareToken = crypto.randomBytes(16).toString('hex');
const shareLink = `${slug}/${shareToken}`;
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
// Hash password
const password_hash = await bcrypt.hash(password, 10);
// Hash password (or placeholder when not required)
const password_hash = requirePassword
? await bcrypt.hash(password, getBcryptRounds())
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
// Calculate expiration date (days after event date)
const expires_at = new Date(event_date);
@@ -70,13 +186,17 @@ router.post('/', adminAuth, [
event_type,
event_name,
event_date,
host_email,
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
host_name: customerName,
host_email: customerEmail,
admin_email,
password_hash,
welcome_message,
color_theme,
share_link: shareLink,
expires_at
share_link: shareLinkToStore,
share_token: shareToken,
expires_at,
require_password: formatBoolean(requirePassword)
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
@@ -84,21 +204,26 @@ router.post('/', adminAuth, [
// Queue creation email
const { queueEmail } = require('../services/emailProcessor');
await queueEmail(eventId, host_email, 'gallery_created', {
host_name: host_email.split('@')[0], // Extract name from email
await queueEmail(eventId, customerEmail, 'gallery_created', {
customer_name: customerName,
customer_email: customerEmail,
host_name: customerName,
event_name,
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareLink,
gallery_password: password,
gallery_link: shareUrl,
gallery_password: requirePassword ? password : 'No password required',
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
});
res.json({
id: eventId,
slug,
share_link: shareLink,
expires_at
share_link: shareUrl,
expires_at,
require_password: requirePassword,
customer_name: customerName,
customer_email: customerEmail
});
} catch (error) {
console.error(error);
@@ -127,27 +252,98 @@ router.get('/', adminAuth, async (req, res) => {
event.photo_count = photoCount.count;
}
res.json(events);
res.json(events.map(mapEventForApi));
} catch (error) {
res.status(500).json({ error: 'Failed to fetch events' });
}
});
// Update event
router.put('/:id', adminAuth, async (req, res) => {
router.put('/:id', adminAuth, [
body('customer_name').optional().trim().notEmpty(),
body('customer_email').optional().isEmail().normalizeEmail(),
body('require_password').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 updates = { ...req.body };
const customerColumnsAvailable = await hasCustomerContactColumns();
// Don't allow updating certain fields
delete updates.id;
delete updates.slug;
delete updates.created_at;
// If updating password, hash it
if (updates.password) {
updates.password_hash = await bcrypt.hash(updates.password, 10);
delete updates.password;
delete updates.password_confirmation;
if (Object.prototype.hasOwnProperty.call(updates, 'host_name') || Object.prototype.hasOwnProperty.call(updates, 'host_email')) {
return res.status(400).json({ error: 'host_name and host_email are no longer supported. Use customer_name and customer_email instead.' });
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_name')) {
const nextName = getCustomerNameFromPayload(updates);
if (nextName) {
if (customerColumnsAvailable) {
updates.customer_name = nextName;
} else {
delete updates.customer_name;
}
updates.host_name = nextName;
} else {
delete updates.customer_name;
}
}
if (Object.prototype.hasOwnProperty.call(updates, 'customer_email')) {
const nextEmail = getCustomerEmailFromPayload(updates);
if (nextEmail) {
if (customerColumnsAvailable) {
updates.customer_email = nextEmail;
} else {
delete updates.customer_email;
}
updates.host_email = nextEmail;
} else {
delete updates.customer_email;
}
}
const hasRequirePasswordUpdate = Object.prototype.hasOwnProperty.call(updates, 'require_password');
let requirePasswordUpdate;
if (hasRequirePasswordUpdate) {
requirePasswordUpdate = parseBooleanInput(updates.require_password, true);
updates.require_password = formatBoolean(requirePasswordUpdate);
}
let newPasswordPlain;
if (Object.prototype.hasOwnProperty.call(updates, 'password')) {
if (updates.password === undefined || updates.password === null || updates.password === '') {
delete updates.password;
} else {
newPasswordPlain = updates.password;
delete updates.password;
}
}
const event = await db('events').where('id', id).first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
const currentRequirePassword = parseBooleanInput(event.require_password, true);
if (hasRequirePasswordUpdate && requirePasswordUpdate === true && !currentRequirePassword && !newPasswordPlain) {
return res.status(400).json({ error: 'Password must be provided when enabling password requirement.' });
}
if (newPasswordPlain) {
updates.password_hash = await bcrypt.hash(newPasswordPlain, getBcryptRounds());
} else if (hasRequirePasswordUpdate && requirePasswordUpdate === false && currentRequirePassword) {
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
}
await db('events').where('id', id).update(updates);
+380 -101
View File
@@ -1,5 +1,4 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const archiver = require('archiver');
@@ -8,28 +7,58 @@ const router = express.Router();
const watermarkService = require('../services/watermarkService');
const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
// Resolve gallery identifier (slug or token) to canonical data
router.get('/resolve/:identifier', async (req, res) => {
try {
const { identifier } = req.params;
const result = await resolveShareIdentifier(identifier);
if (!result) {
return res.status(404).json({ error: 'Gallery not found' });
}
const { event, matchType, shareToken } = result;
const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken });
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({
slug: event.slug,
token: shareToken,
matchType,
share_link: event.share_link,
share_path: linkVariants.sharePath,
share_url: linkVariants.shareUrl,
short_enabled: linkVariants.shortEnabled,
requires_password: requiresPassword
});
} catch (error) {
logger.error('Error resolving gallery identifier:', error);
res.status(500).json({ error: 'Failed to resolve gallery link' });
}
});
// Verify share token
router.get('/:slug/verify-token/:token', async (req, res) => {
try {
const { slug, token } = req.params;
const event = await db('events')
.where({ share_link: slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('id', 'share_link')
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('id', 'share_link', 'share_token')
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
// Extract token from share link and verify
const expectedToken = event.share_link.split('/').pop();
const expectedToken = getEventShareToken(event);
if (token !== expectedToken) {
return res.status(404).json({ error: 'Invalid gallery link' });
}
@@ -37,7 +66,7 @@ router.get('/:slug/verify-token/:token', async (req, res) => {
res.json({ valid: true });
} catch (error) {
console.error('Error verifying token:', error);
res.status(500).json({ error: 'Failed to verify token', details: error.message });
res.status(500).json({ error: 'Failed to verify token' });
}
});
@@ -48,9 +77,23 @@ router.get('/:slug/info', async (req, res) => {
const { token } = req.query;
const event = await db('events')
.where({ slug: slug })
.select('event_name', 'event_type', 'event_date', 'expires_at', 'is_active', 'is_archived', 'share_link',
'allow_downloads', 'disable_right_click', 'watermark_downloads', 'watermark_text')
.where({ slug })
.select(
'event_name',
'event_type',
'event_date',
'expires_at',
'is_active',
'is_archived',
'share_link',
'share_token',
'allow_downloads',
'disable_right_click',
'watermark_downloads',
'watermark_text',
'require_password',
'color_theme'
)
.first();
if (!event) {
@@ -64,16 +107,14 @@ router.get('/:slug/info', async (req, res) => {
// If token provided, verify it matches the share link
if (token) {
let expectedToken = event.share_link;
// Handle both formats: full URL or just token
if (event.share_link && event.share_link.includes('/')) {
expectedToken = event.share_link.split('/').pop();
}
if (token !== expectedToken) {
const expectedToken = getEventShareToken(event);
if (!expectedToken || token !== expectedToken) {
return res.status(404).json({ error: 'Invalid gallery link' });
}
}
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
res.json({
event_name: event.event_name,
event_type: event.event_type,
@@ -81,28 +122,110 @@ router.get('/:slug/info', async (req, res) => {
expires_at: event.expires_at,
is_active: event.is_active,
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
requires_password: true,
requires_password: requiresPassword,
color_theme: event.color_theme,
allow_downloads: event.allow_downloads !== false,
disable_right_click: event.disable_right_click === true,
watermark_downloads: event.watermark_downloads === true,
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
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
});
} catch (error) {
console.error('Error fetching gallery info:', error);
res.status(500).json({ error: 'Failed to fetch gallery info', details: error.message });
res.status(500).json({ error: 'Failed to fetch gallery info' });
}
});
// 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
const photos = await db('photos')
let photos = await db('photos')
.where('photos.event_id', req.event.id)
.select('photos.*')
.orderBy('photos.uploaded_at', 'desc');
// Apply filtering if requested (supports global stats + per-guest interactions)
if (filter) {
const filterTokens = new Set(
String(filter)
.toLowerCase()
.split(',')
.map(token => token.trim())
.filter(Boolean)
);
if (filterTokens.size > 0) {
// Treat "saved" / "favorite" synonyms as favorites
if (filterTokens.has('saved')) {
filterTokens.add('favorited');
}
if (filterTokens.has('favorite')) {
filterTokens.add('favorited');
}
const include = new Set();
const includeBy = (predicate) => {
photos.forEach(photo => {
if (predicate(photo)) {
include.add(photo.id);
}
});
};
let guestFeedbackByType = null;
if (guest_id) {
const guestFeedbackRows = await db('photo_feedback')
.where({ event_id: req.event.id, guest_identifier: guest_id })
.select('photo_id', 'feedback_type');
guestFeedbackByType = guestFeedbackRows.reduce((acc, row) => {
if (!acc[row.feedback_type]) {
acc[row.feedback_type] = new Set();
}
acc[row.feedback_type].add(row.photo_id);
return acc;
}, {});
}
const includeGuestMatches = (type) => {
const ids = guestFeedbackByType?.[type];
if (ids && ids.size > 0) {
ids.forEach(id => include.add(id));
}
};
if (filterTokens.has('liked')) {
includeGuestMatches('like');
includeBy(photo => (photo.like_count || 0) > 0);
}
if (filterTokens.has('favorited')) {
includeGuestMatches('favorite');
includeBy(photo => (photo.favorite_count || 0) > 0);
}
if (filterTokens.has('rated')) {
includeGuestMatches('rating');
includeBy(photo => (photo.average_rating || 0) > 0);
}
if (filterTokens.has('commented')) {
includeGuestMatches('comment');
const commentedRows = await db('photo_feedback')
.where({ event_id: req.event.id, feedback_type: 'comment', is_approved: true, is_hidden: false })
.groupBy('photo_id')
.select('photo_id');
commentedRows.forEach(row => include.add(row.photo_id));
}
photos = photos.filter(photo => include.has(photo.id));
}
}
// Then get comment counts separately
const commentCounts = await db('photo_feedback')
.whereIn('photo_id', photos.map(p => p.id))
@@ -150,13 +273,6 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
overlay_protection: req.event.overlay_protection !== false
};
console.log('[Gallery Photos] Event data:', {
id: req.event.id,
slug: req.params.slug,
protection_level: req.event.protection_level,
calculated_protection: protectionSettings.protection_level,
is_basic_or_standard: (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard')
});
res.json({
event: {
@@ -181,8 +297,6 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
console.log(`[Photo ${photo.id}] Protection: ${protectionSettings.protection_level}, Use JWT: ${useJwtUrl}, URL: ${photoUrl}`);
return {
id: photo.id,
filename: photo.filename,
@@ -209,7 +323,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
});
} catch (error) {
console.error('Error fetching photos:', error);
res.status(500).json({ error: 'Failed to fetch photos', details: error.message });
res.status(500).json({ error: 'Failed to fetch photos' });
}
});
@@ -243,16 +357,17 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
photo_id: photoId
});
// Photo path should be in storage/events/active directory
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
const storagePath = getStoragePath();
let filePath;
if (photo.path.startsWith('events/active/')) {
// New format: path already includes events/active/ prefix
filePath = path.join(storagePath, photo.path);
} else {
// Legacy format: path is just slug/filename
filePath = path.join(storagePath, 'events/active', photo.path);
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path for download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Get watermark settings
@@ -271,9 +386,24 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
res.send(watermarkedBuffer);
} else {
// Send original file
res.download(filePath, photo.filename);
res.download(filePath, photo.filename, (downloadError) => {
if (downloadError) {
logger.error('Error streaming gallery download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: downloadError.message,
});
}
});
}
} catch (error) {
logger.error('Unexpected error processing gallery download', {
slug: req.params.slug,
photoId: req.params.photoId,
eventId: req.event?.id,
error: error.message,
});
res.status(500).json({ error: 'Failed to download photo' });
}
});
@@ -316,16 +446,17 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
// Add photos to archive
for (const photo of photos) {
// Photo path should be in storage/events/active directory
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
const storagePath = getStoragePath();
let filePath;
if (photo.path.startsWith('events/active/')) {
// New format: path already includes events/active/ prefix
filePath = path.join(storagePath, photo.path);
} else {
// Legacy format: path is just slug/filename
filePath = path.join(storagePath, 'events/active', photo.path);
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.warn('Skipping photo in bulk download due to unresolved path', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: resolveError.message,
});
continue;
}
// Determine the file name in the archive
@@ -340,11 +471,18 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
}
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name: archiveName });
try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name: archiveName });
} catch (watermarkError) {
logger.warn('Failed to watermark photo for bulk download, skipping original to avoid leak', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: watermarkError.message,
});
}
} else {
// Add original file
archive.file(filePath, { name: archiveName });
}
}
@@ -359,61 +497,154 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
action: 'download_all'
});
} catch (error) {
logger.error('Error creating bulk gallery download', {
slug: req.params.slug,
eventId: req.event?.id,
error: error.message,
});
res.status(500).json({ error: 'Failed to create download archive' });
}
});
// Test route
router.get('/:slug/photo-test/:photoId',
verifyGalleryAccess,
(req, res) => {
console.log('TEST ROUTE EXECUTED!');
res.json({ message: 'Test route works!', photoId: req.params.photoId });
// Download selected photos as ZIP
router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) => {
try {
// 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 ids = Array.isArray(req.body?.photo_ids) ? req.body.photo_ids : [];
if (!ids.length) {
return res.status(400).json({ error: 'photo_ids is required (non-empty array)' });
}
// Clean IDs
const photoIds = ids
.map((v) => parseInt(v, 10))
.filter((v) => Number.isInteger(v))
.slice(0, 500);
if (photoIds.length === 0) {
return res.status(400).json({ error: 'No valid photo IDs provided' });
}
// Fetch photos
const photos = await db('photos')
.where('photos.event_id', req.event.id)
.whereIn('photos.id', photoIds)
.select('photos.*')
.orderBy('photos.uploaded_at', 'desc');
if (photos.length === 0) {
return res.status(404).json({ error: 'No photos found for selected IDs' });
}
const archiveName = `${req.event.slug}-selected.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${archiveName}"`);
const archive = archiver('zip', { zlib: { level: 5 } });
archive.on('error', (err) => {
logger.error('Zip error generating selected download', {
slug: req.params.slug,
eventId: req.event?.id,
error: err.message,
});
try {
res.status(500).end();
} catch (_) {
// ignore double-send errors
}
});
archive.pipe(res);
// Check watermark settings similar to download-all
const watermarkSettings = await watermarkService.getWatermarkSettings();
for (const photo of photos) {
try {
const filePath = resolvePhotoFilePath(req.event, photo);
const name = photo.filename || `photo-${photo.id}.jpg`;
if (watermarkSettings && watermarkSettings.enabled) {
try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name });
} catch (watermarkError) {
logger.warn('Failed to watermark selected photo, skipping original to avoid leak', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: watermarkError.message,
});
}
} else {
archive.file(filePath, { name });
}
} catch (resolveError) {
logger.warn('Skipping selected photo due to unresolved path', {
slug: req.params.slug,
photoId: photo.id,
eventId: req.event.id,
error: resolveError.message,
});
}
}
await archive.finalize();
await db('access_logs').insert({
event_id: req.event.id,
ip_address: req.ip,
user_agent: req.headers['user-agent'],
action: 'download_selected'
});
} catch (error) {
logger.error('Error in download-selected:', {
slug: req.params.slug,
eventId: req.event?.id,
error: error.message,
});
res.status(500).json({ error: 'Failed to download selected photos' });
}
);
});
// View single photo (with watermark if enabled)
router.get('/:slug/photo/:photoId',
verifyGalleryAccess,
router.get('/:slug/photo/: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' });
}
// Check if this is a video
const isVideo = photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'));
// Check protection level - basic and standard protection allow direct JWT access
const protectionLevel = req.event.protection_level || 'standard';
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
// For enhanced/maximum protection, redirect to secure endpoint
return res.status(302).json({
return res.status(302).json({
error: 'Secure access required',
secureEndpoint: `/api/secure-images/${req.params.slug}/generate-token`,
photoId: photoId
});
}
// Photo path should be in storage/events/active directory
// Handle both legacy paths (just slug/filename) and new paths (events/active/slug/filename)
const storagePath = getStoragePath();
let filePath;
if (photo.path.startsWith('events/active/')) {
// New format: path already includes events/active/ prefix
filePath = path.join(storagePath, photo.path);
} else {
// Legacy format: path is just slug/filename
filePath = path.join(storagePath, 'events/active', photo.path);
}
// 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);
// Log access - temporarily disabled for debugging
// await secureImageService.logImageAccess(
// photoId,
@@ -421,20 +652,61 @@ router.get('/:slug/photo/:photoId',
// req.clientInfo,
// 'view_basic'
// );
// 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 start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
const chunksize = (end - start) + 1;
const file = fs.createReadStream(filePath, { start, end });
res.writeHead(206, {
'Content-Range': `bytes ${start}-${end}/${fileSize}`,
'Accept-Ranges': 'bytes',
'Content-Length': chunksize,
'Content-Type': photo.mime_type || 'video/mp4',
'Cache-Control': 'private, max-age=1800',
'X-Protection-Level': 'basic'
});
file.pipe(res);
} else {
// No range request, send entire file
res.writeHead(200, {
'Content-Length': fileSize,
'Content-Type': photo.mime_type || 'video/mp4',
'Accept-Ranges': 'bytes',
'Cache-Control': 'private, max-age=1800',
'X-Protection-Level': 'basic'
});
fs.createReadStream(filePath).pipe(res);
}
return;
}
// Handle images (existing logic)
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
if (watermarkSettings && watermarkSettings.enabled) {
// Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
res.set({
'Content-Type': photo.mime_type || 'image/jpeg',
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
'X-Protection-Level': 'basic'
});
res.send(watermarkedBuffer);
} else {
// Send original file with basic protection headers
@@ -453,7 +725,7 @@ router.get('/:slug/photo/:photoId',
photoId: req.params.photoId,
eventId: req.event?.id
});
res.status(500).json({ error: 'Failed to serve photo', details: error.message });
res.status(500).json({ error: 'Failed to serve photo' });
}
}
);
@@ -572,22 +844,35 @@ router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
try {
const eventId = parseInt(req.params.eventId);
// Verify the event matches the token
if (req.event.id !== eventId) {
return res.status(403).json({ error: 'Access denied' });
}
// Check if user uploads are allowed
if (!req.event.allow_user_uploads) {
return res.status(403).json({ error: 'User uploads are not allowed for this event' });
}
// Ensure temp upload directory exists
const fs = require('fs');
const tempUploadDir = '/tmp/uploads/';
if (!fs.existsSync(tempUploadDir)) {
try {
fs.mkdirSync(tempUploadDir, { recursive: true, mode: 0o755 });
logger.info('Created temp upload directory:', tempUploadDir);
} catch (mkdirErr) {
logger.error('Failed to create temp upload directory:', mkdirErr);
return res.status(500).json({ error: 'Server configuration error: unable to create upload directory' });
}
}
// Import multer and photo processing
const multer = require('multer');
const upload = multer({
dest: '/tmp/uploads/',
limits: {
const upload = multer({
dest: tempUploadDir,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB
files: 10 // Max 10 files at once
},
@@ -619,12 +904,6 @@ router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
// Process uploaded photos
const results = await processUploadedPhotos(req.files, eventId, 'user', categoryId);
// Clean up temp files
const fs = require('fs').promises;
for (const file of req.files) {
await fs.unlink(file.path).catch(console.error);
}
res.json({
message: 'Photos uploaded successfully',
count: results.length,
+8 -7
View File
@@ -24,14 +24,15 @@ router.get('/:slug/feedback-settings',
const settings = await feedbackService.getEventFeedbackSettings(event.id);
// Only send relevant settings to guests
// Convert SQLite boolean values (0/1) to proper booleans
const guestSettings = {
feedback_enabled: settings.feedback_enabled,
allow_ratings: settings.allow_ratings,
allow_likes: settings.allow_likes,
allow_comments: settings.allow_comments,
allow_favorites: settings.allow_favorites,
require_name_email: settings.require_name_email,
show_feedback_to_guests: settings.show_feedback_to_guests
feedback_enabled: Boolean(settings.feedback_enabled),
allow_ratings: Boolean(settings.allow_ratings),
allow_likes: Boolean(settings.allow_likes),
allow_comments: Boolean(settings.allow_comments),
allow_favorites: Boolean(settings.allow_favorites),
require_name_email: Boolean(settings.require_name_email),
show_feedback_to_guests: Boolean(settings.show_feedback_to_guests)
};
res.json(guestSettings);
+6
View File
@@ -42,6 +42,12 @@ router.get('/', async (req, res) => {
branding_watermark_size: settingsObject.branding_watermark_size || 15,
branding_favicon_url: settingsObject.branding_favicon_url || '',
branding_logo_url: settingsObject.branding_logo_url || '',
branding_logo_size: settingsObject.branding_logo_size || 'medium',
branding_logo_max_height: settingsObject.branding_logo_max_height || 48,
branding_logo_position: settingsObject.branding_logo_position || 'left',
branding_logo_display_header: settingsObject.branding_logo_display_header !== false,
branding_logo_display_hero: settingsObject.branding_logo_display_hero !== false,
branding_logo_display_mode: settingsObject.branding_logo_display_mode || 'logo_and_text',
theme_config: settingsObject.theme_config || null,
default_language: settingsObject.general_default_language || 'en',
enable_analytics: settingsObject.general_enable_analytics !== false,
+30 -11
View File
@@ -1,17 +1,14 @@
const express = require('express');
const path = require('path');
const { db } = require('../database/db');
const { verifyGalleryAccess } = require('../middleware/gallery');
const secureImageService = require('../services/secureImageService');
const secureImageMiddleware = require('../middleware/secureImageMiddleware');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const router = express.Router();
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/**
* Generate secure token for image access
*/
@@ -94,11 +91,11 @@ router.get('/:slug/secure/:photoId/:token',
const { slug, photoId, token } = req.params; // Move outside try block for error handler access
try {
console.log('Secure image route hit:', {
slug: slug,
photoId: photoId,
logger.debug('Secure image route hit', {
slug,
photoId,
tokenLength: token?.length,
headers: req.headers.authorization ? 'present' : 'absent'
hasAuthHeader: Boolean(req.headers.authorization),
});
const { fragment } = req.query;
@@ -142,7 +139,18 @@ router.get('/:slug/secure/:photoId/:token',
return res.status(404).json({ error: 'Photo not found' });
}
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
let filePath;
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path for secure token generation', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Get protection settings for this event
const protectionSettings = {
@@ -284,7 +292,18 @@ router.get('/:slug/secure-download/:photoId/:token',
return res.status(404).json({ error: 'Photo not found' });
}
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
let filePath;
try {
filePath = resolvePhotoFilePath(req.event, photo);
} catch (resolveError) {
logger.error('Failed to resolve photo path for secure download', {
slug: req.params.slug,
photoId,
eventId: req.event.id,
error: resolveError.message,
});
return res.status(404).json({ error: 'Photo file not found' });
}
// Apply watermark if enabled
const watermarkService = require('../services/watermarkService');
@@ -426,4 +445,4 @@ async function getSuspiciousActivityStats() {
}
}
module.exports = router;
module.exports = router;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,285 @@
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
const logger = require('../utils/logger');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const getChunksPath = () => path.join(getStoragePath(), 'chunks');
// In-memory store for active uploads (in production, consider Redis)
const activeUploads = new Map();
// Chunk size: 10MB
const CHUNK_SIZE = 10 * 1024 * 1024;
// Upload expiration: 24 hours
const UPLOAD_EXPIRATION_MS = 24 * 60 * 60 * 1000;
/**
* Initialize a new chunked upload
* @param {Object} options - Upload options
* @returns {Promise<Object>} - Upload metadata
*/
async function initializeUpload(options) {
const {
filename,
fileSize,
mimeType,
eventId,
totalChunks
} = options;
// Generate unique upload ID
const uploadId = crypto.randomUUID();
// Create chunks directory for this upload
const uploadDir = path.join(getChunksPath(), uploadId);
await fs.mkdir(uploadDir, { recursive: true });
// Calculate expected chunks
const expectedChunks = totalChunks || Math.ceil(fileSize / CHUNK_SIZE);
// Store upload metadata
const uploadMeta = {
uploadId,
filename,
fileSize,
mimeType,
eventId,
expectedChunks,
receivedChunks: new Set(),
uploadDir,
createdAt: Date.now(),
expiresAt: Date.now() + UPLOAD_EXPIRATION_MS,
status: 'in_progress'
};
activeUploads.set(uploadId, uploadMeta);
logger.info('Initialized chunked upload', {
uploadId,
filename,
fileSize,
expectedChunks,
eventId
});
return {
uploadId,
chunkSize: CHUNK_SIZE,
expectedChunks,
expiresAt: uploadMeta.expiresAt
};
}
/**
* Upload a single chunk
* @param {string} uploadId - Upload ID
* @param {number} chunkIndex - Chunk index (0-based)
* @param {Buffer} chunkData - Chunk data
* @returns {Promise<Object>} - Chunk upload result
*/
async function uploadChunk(uploadId, chunkIndex, chunkData) {
const uploadMeta = activeUploads.get(uploadId);
if (!uploadMeta) {
throw new Error('Upload not found or expired');
}
if (uploadMeta.status !== 'in_progress') {
throw new Error(`Upload is ${uploadMeta.status}`);
}
// Check expiration
if (Date.now() > uploadMeta.expiresAt) {
await abortUpload(uploadId);
throw new Error('Upload expired');
}
// Write chunk to disk
const chunkPath = path.join(uploadMeta.uploadDir, `chunk_${String(chunkIndex).padStart(6, '0')}`);
await fs.writeFile(chunkPath, chunkData);
// Mark chunk as received
uploadMeta.receivedChunks.add(chunkIndex);
const progress = (uploadMeta.receivedChunks.size / uploadMeta.expectedChunks) * 100;
logger.debug('Chunk uploaded', {
uploadId,
chunkIndex,
receivedChunks: uploadMeta.receivedChunks.size,
expectedChunks: uploadMeta.expectedChunks,
progress: progress.toFixed(1)
});
return {
chunkIndex,
received: uploadMeta.receivedChunks.size,
expected: uploadMeta.expectedChunks,
progress,
complete: uploadMeta.receivedChunks.size === uploadMeta.expectedChunks
};
}
/**
* Complete the upload by merging all chunks
* @param {string} uploadId - Upload ID
* @returns {Promise<Object>} - Merged file info
*/
async function completeUpload(uploadId) {
const uploadMeta = activeUploads.get(uploadId);
if (!uploadMeta) {
throw new Error('Upload not found or expired');
}
// Verify all chunks received
if (uploadMeta.receivedChunks.size !== uploadMeta.expectedChunks) {
throw new Error(`Missing chunks: received ${uploadMeta.receivedChunks.size} of ${uploadMeta.expectedChunks}`);
}
uploadMeta.status = 'merging';
// Create temp file for merged result
const tempDir = path.join(getStoragePath(), 'temp', `merge_${Date.now()}_${Math.random().toString(36).substring(7)}`);
await fs.mkdir(tempDir, { recursive: true });
const mergedFilePath = path.join(tempDir, uploadMeta.filename);
const writeStream = require('fs').createWriteStream(mergedFilePath);
try {
// Merge chunks in order
for (let i = 0; i < uploadMeta.expectedChunks; i++) {
const chunkPath = path.join(uploadMeta.uploadDir, `chunk_${String(i).padStart(6, '0')}`);
const chunkData = await fs.readFile(chunkPath);
await new Promise((resolve, reject) => {
writeStream.write(chunkData, (err) => {
if (err) reject(err);
else resolve();
});
});
}
await new Promise((resolve) => writeStream.end(resolve));
// Verify file size
const stats = await fs.stat(mergedFilePath);
if (stats.size !== uploadMeta.fileSize) {
logger.warn('Merged file size mismatch', {
expected: uploadMeta.fileSize,
actual: stats.size
});
}
// Clean up chunks
await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true });
uploadMeta.status = 'completed';
activeUploads.delete(uploadId);
logger.info('Chunked upload completed', {
uploadId,
filename: uploadMeta.filename,
fileSize: stats.size,
eventId: uploadMeta.eventId
});
return {
path: mergedFilePath,
filename: uploadMeta.filename,
size: stats.size,
mimeType: uploadMeta.mimeType,
eventId: uploadMeta.eventId,
tempDir
};
} catch (error) {
writeStream.destroy();
uploadMeta.status = 'failed';
throw error;
}
}
/**
* Abort and clean up an upload
* @param {string} uploadId - Upload ID
*/
async function abortUpload(uploadId) {
const uploadMeta = activeUploads.get(uploadId);
if (uploadMeta) {
try {
await fs.rm(uploadMeta.uploadDir, { recursive: true, force: true });
} catch (err) {
logger.warn('Failed to clean up upload directory', { uploadId, error: err.message });
}
activeUploads.delete(uploadId);
logger.info('Chunked upload aborted', { uploadId });
}
}
/**
* Get upload status
* @param {string} uploadId - Upload ID
* @returns {Object|null} - Upload status or null if not found
*/
function getUploadStatus(uploadId) {
const uploadMeta = activeUploads.get(uploadId);
if (!uploadMeta) {
return null;
}
return {
uploadId,
filename: uploadMeta.filename,
fileSize: uploadMeta.fileSize,
receivedChunks: uploadMeta.receivedChunks.size,
expectedChunks: uploadMeta.expectedChunks,
progress: (uploadMeta.receivedChunks.size / uploadMeta.expectedChunks) * 100,
status: uploadMeta.status,
createdAt: uploadMeta.createdAt,
expiresAt: uploadMeta.expiresAt
};
}
/**
* Clean up expired uploads
*/
async function cleanupExpiredUploads() {
const now = Date.now();
const expiredIds = [];
for (const [uploadId, meta] of activeUploads.entries()) {
if (now > meta.expiresAt) {
expiredIds.push(uploadId);
}
}
for (const uploadId of expiredIds) {
await abortUpload(uploadId);
}
if (expiredIds.length > 0) {
logger.info(`Cleaned up ${expiredIds.length} expired uploads`);
}
return expiredIds.length;
}
// Run cleanup every hour
setInterval(cleanupExpiredUploads, 60 * 60 * 1000);
module.exports = {
initializeUpload,
uploadChunk,
completeUpload,
abortUpload,
getUploadStatus,
cleanupExpiredUploads,
CHUNK_SIZE
};
+13 -3
View File
@@ -9,7 +9,7 @@ let lastConfigHash = null;
// Generate hash from config for change detection
function generateConfigHash(config) {
const crypto = require('crypto');
const configString = `${config.smtp_host}:${config.smtp_port}:${config.smtp_user}:${config.smtp_pass}:${config.smtp_secure}`;
const configString = `${config.smtp_host}:${config.smtp_port}:${config.smtp_user}:${config.smtp_pass}:${config.smtp_secure}:${config.tls_reject_unauthorized}`;
return crypto.createHash('md5').update(configString).digest('hex');
}
@@ -40,7 +40,11 @@ async function initializeTransporter(forceReinit = false) {
auth: config.smtp_user ? {
user: config.smtp_user,
pass: config.smtp_pass
} : undefined
} : undefined,
tls: {
// Allow ignoring SSL certificate errors when tls_reject_unauthorized is false
rejectUnauthorized: config.tls_reject_unauthorized !== false
}
});
// Verify configuration
@@ -132,6 +136,12 @@ async function processTemplate(template, variables, language = 'en') {
? '(Aus Sicherheitsgründen nicht angezeigt)'
: '(Not shown for security reasons)';
}
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) {
@@ -546,4 +556,4 @@ module.exports = {
queueEmail,
stopEmailQueueProcessor,
testEmailConnection
};
};
+15 -6
View File
@@ -58,11 +58,15 @@ async function queueExpirationWarning(event) {
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
// Determine language based on email domain
const emailLang = event.host_email.endsWith('.de') ? 'de' : 'en';
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
const emailLang = recipientEmail && recipientEmail.endsWith('.de') ? 'de' : 'en';
// Queue email to host
await queueEmail(event.id, event.host_email, 'expiration_warning', {
host_name: event.host_name || event.host_email.split('@')[0],
// Queue email to customer
await queueEmail(event.id, recipientEmail, 'expiration_warning', {
customer_name: recipientName,
customer_email: recipientEmail,
host_name: recipientName,
event_name: event.event_name,
days_remaining: daysRemaining.toString(),
expiration_date: await formatDate(event.expires_at, emailLang),
@@ -78,9 +82,14 @@ async function handleExpiredEvent(event) {
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
// Queue expiration emails
await queueEmail(event.id, event.host_email, 'gallery_expired', {
const recipientEmail = event.customer_email || event.host_email;
const recipientName = event.customer_name || event.host_name || (recipientEmail ? recipientEmail.split('@')[0] : null);
await queueEmail(event.id, recipientEmail, 'gallery_expired', {
event_name: event.event_name,
admin_email: event.admin_email
admin_email: event.admin_email,
customer_name: recipientName,
customer_email: recipientEmail
});
// Also notify admin
@@ -0,0 +1,109 @@
const fs = require('fs').promises;
const fsSync = require('fs');
const path = require('path');
const { safePathJoin } = require('../utils/fileSecurityUtils');
let cachedRoot = null;
function resolveDefaultRoot() {
const containerDefault = '/external-media';
try {
if (fsSync.existsSync(containerDefault)) {
return containerDefault;
}
} catch (error) {
// ignore lookup errors, fallback below
}
const localFallback = path.resolve(__dirname, '../../..', 'storage/external-media');
try {
if (fsSync.existsSync(localFallback)) {
return localFallback;
}
} catch (error) {
// ignore and return container default
}
return containerDefault;
}
function getExternalMediaRoot() {
if (cachedRoot) {
return cachedRoot;
}
const configured = process.env.EXTERNAL_MEDIA_ROOT;
if (configured && configured.trim()) {
const resolvedConfigured = path.resolve(configured.trim());
try {
if (fsSync.existsSync(resolvedConfigured)) {
cachedRoot = resolvedConfigured;
return cachedRoot;
}
} catch (error) {
// ignore lookup errors and fall back to defaults
}
}
cachedRoot = resolveDefaultRoot();
return cachedRoot;
}
function isUnderRoot(p) {
const root = path.resolve(getExternalMediaRoot());
const resolved = path.resolve(p);
return resolved === root || resolved.startsWith(root + path.sep);
}
async function list(relativePath = '') {
const root = getExternalMediaRoot();
// Normalize and ensure safe join under root
const targetDir = safePathJoin(root, relativePath || '.');
const entries = [];
try {
const dirents = await fs.readdir(targetDir, { withFileTypes: true });
for (const d of dirents) {
// Skip hidden files and directories
if (d.name.startsWith('.')) continue;
const full = path.join(targetDir, d.name);
const stat = await fs.stat(full).catch(() => null);
if (!stat) continue;
if (d.isDirectory()) {
entries.push({ name: d.name, type: 'dir' });
} else if (d.isFile()) {
const ext = path.extname(d.name).toLowerCase();
if (['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) {
entries.push({ name: d.name, type: 'file', size: stat.size, mtime: stat.mtime });
}
}
}
} catch (e) {
// Propagate errors for caller to handle (e.g., invalid path)
throw e;
}
const rootResolved = path.resolve(root);
const currentResolved = path.resolve(targetDir);
const canNavigateUp = currentResolved !== rootResolved;
// Return normalized relative path from root
const relFromRoot = path.relative(rootResolved, currentResolved);
return { path: relFromRoot, entries, canNavigateUp };
}
function resolveExternalPath(event, relpath) {
const root = getExternalMediaRoot();
const base = event?.external_path ? path.join(event.external_path) : '';
const combined = base ? path.join(base, relpath || '') : (relpath || '');
return safePathJoin(root, combined);
}
module.exports = {
getExternalMediaRoot,
isUnderRoot,
list,
resolveExternalPath,
};
+70
View File
@@ -390,6 +390,76 @@ class FeedbackService {
throw error;
}
}
/**
* Get filtered photos based on feedback criteria
* @param {number} eventId - Event ID
* @param {string} guestIdentifier - Guest identifier
* @param {object} filters - Filter criteria
* @param {boolean} filters.liked - Include liked photos
* @param {boolean} filters.favorited - Include favorited photos
* @param {string} filters.operator - 'AND' or 'OR' for multiple filters
* @returns {Promise<number[]>} Array of photo IDs that match criteria
*/
async getFilteredPhotos(eventId, guestIdentifier, filters = {}) {
try {
const { liked, favorited, operator = 'OR' } = filters;
// If no filters specified, return all photos
if (!liked && !favorited) {
const allPhotos = await db('photos')
.where('event_id', eventId)
.select('id');
return allPhotos.map(p => p.id);
}
// Build query based on filters
let query = db('photo_feedback')
.where('event_id', eventId)
.where('guest_identifier', guestIdentifier)
.where('is_hidden', false);
// Apply filter logic
if (operator === 'AND' && liked && favorited) {
// For AND operation, we need photos that have both types of feedback
const likedPhotos = await db('photo_feedback')
.where('event_id', eventId)
.where('guest_identifier', guestIdentifier)
.where('feedback_type', 'like')
.where('is_hidden', false)
.select('photo_id');
const favoritedPhotos = await db('photo_feedback')
.where('event_id', eventId)
.where('guest_identifier', guestIdentifier)
.where('feedback_type', 'favorite')
.where('is_hidden', false)
.select('photo_id');
const likedIds = new Set(likedPhotos.map(p => p.photo_id));
const favoritedIds = new Set(favoritedPhotos.map(p => p.photo_id));
// Return intersection of both sets
return Array.from(likedIds).filter(id => favoritedIds.has(id));
} else {
// OR operation or single filter
const feedbackTypes = [];
if (liked) feedbackTypes.push('like');
if (favorited) feedbackTypes.push('favorite');
query.whereIn('feedback_type', feedbackTypes);
}
const filteredPhotos = await query
.distinct('photo_id')
.select('photo_id');
return filteredPhotos.map(p => p.photo_id);
} catch (error) {
logger.error('Error getting filtered photos:', error);
throw error;
}
}
}
module.exports = new FeedbackService();
+18 -7
View File
@@ -3,8 +3,10 @@ const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail } = require('./imageProcessor');
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
const logger = require('../utils/logger');
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
const mime = require('mime-types');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const WATCH_PATH = () => path.join(getStoragePath(), 'events/active');
@@ -47,9 +49,11 @@ async function processNewPhoto(filePath) {
const eventSlug = pathParts[0];
const photoType = pathParts[1] === 'collages' ? 'collage' : 'individual';
// Check if this is an image file
// Check if this is an image or video file
const ext = path.extname(filePath).toLowerCase();
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
const detectedMime = mime.lookup(filePath) || '';
const isVideo = isVideoMimeType(detectedMime, filePath) || ['.mp4', '.mov', '.webm'].includes(ext);
if (!isVideo && !['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
// Skip temporary upload files
const filename = path.basename(filePath);
@@ -65,11 +69,17 @@ async function processNewPhoto(filePath) {
// Get file stats
const stats = await fs.stat(filePath);
// Generate thumbnail
const thumbnailPath = await generateThumbnail(filePath);
// Generate thumbnail or placeholder
let thumbnailPath = null;
if (isVideo) {
thumbnailPath = await generateVideoPlaceholder(filename);
} else {
thumbnailPath = await generateThumbnail(filePath);
}
// Calculate relative thumbnail path
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg');
// Check if photo already exists
const existingPhoto = await db('photos')
@@ -83,8 +93,9 @@ async function processNewPhoto(filePath) {
filename: path.basename(filePath),
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: stats.size
type: isVideo ? 'video' : photoType,
size_bytes: stats.size,
mime_type: mimeType
});
logger.info(`Added new photo: ${relativePath}`);
+95 -8
View File
@@ -18,6 +18,29 @@ const DEFAULT_THUMBNAIL_FORMAT = 'jpeg';
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
// Helper to parse setting value (handles both JSON-encoded and plain values)
function parseSettingValue(value) {
if (value === null || value === undefined) {
return null;
}
// Try to parse as JSON first (in case it's a JSON-encoded string like '"cover"')
try {
return JSON.parse(value);
} catch (e) {
// If it's not valid JSON, return the raw value
return value;
}
}
// Validate that fit value is valid for Sharp
function validateFitValue(fit) {
const validFitValues = ['cover', 'contain', 'fill', 'inside', 'outside'];
if (fit && validFitValues.includes(fit)) {
return fit;
}
return DEFAULT_THUMBNAIL_FIT;
}
// Get thumbnail settings from database
async function getThumbnailSettings() {
try {
@@ -30,16 +53,19 @@ async function getThumbnailSettings() {
'thumbnail_format'
])
.select('setting_key', 'setting_value');
const settingsMap = {};
settings.forEach(s => {
settingsMap[s.setting_key] = s.setting_value;
settingsMap[s.setting_key] = parseSettingValue(s.setting_value);
});
// Parse and validate fit value
const fitValue = validateFitValue(settingsMap.thumbnail_fit);
return {
width: parseInt(settingsMap.thumbnail_width) || DEFAULT_THUMBNAIL_WIDTH,
height: parseInt(settingsMap.thumbnail_height) || DEFAULT_THUMBNAIL_HEIGHT,
fit: settingsMap.thumbnail_fit || DEFAULT_THUMBNAIL_FIT,
fit: fitValue,
quality: parseInt(settingsMap.thumbnail_quality) || DEFAULT_THUMBNAIL_QUALITY,
format: settingsMap.thumbnail_format || DEFAULT_THUMBNAIL_FORMAT
};
@@ -132,7 +158,8 @@ async function generateThumbnail(imagePath, options = {}) {
return path.relative(getStoragePath(), thumbnailPath);
} catch (error) {
logger.error(`Failed to generate thumbnail for ${filename}:`, error.message);
const msg = (error && error.message) ? error.message : String(error);
logger.error(`Failed to generate thumbnail for ${filename}: ${msg}`);
// Clean up any partially created file
try {
@@ -171,8 +198,18 @@ async function isThumbnailValid(thumbnailPath) {
* Regenerate thumbnail if it's broken or missing
*/
async function ensureThumbnail(photo) {
const storagePath = getStoragePath();
const originalPath = path.join(storagePath, 'events/active', photo.path);
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 thumbnail 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 thumbnail (photo ${photo.id}): ${msg}`);
return null;
}
// Check if thumbnail exists and is valid
if (photo.thumbnail_path) {
@@ -200,4 +237,54 @@ async function ensureThumbnail(photo) {
return null;
}
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail };
async function generateVideoPlaceholder(originalFilename, options = {}) {
const parsed = path.parse(originalFilename || '');
const baseName = parsed.name || 'video';
const thumbnailDir = getThumbnailPath();
const thumbnailFilename = `thumb_${baseName}.jpg`;
const thumbnailPath = path.join(thumbnailDir, thumbnailFilename);
const settings = await getThumbnailSettings();
const width = settings.width || DEFAULT_THUMBNAIL_WIDTH;
const height = settings.height || DEFAULT_THUMBNAIL_HEIGHT;
if (options.regenerate) {
try {
await fs.unlink(thumbnailPath);
} catch (_) {
// ignore if missing
}
}
try {
await fs.mkdir(thumbnailDir, { recursive: true });
const svg = `
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f172a" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#1e293b" stop-opacity="0.9"/>
</linearGradient>
</defs>
<rect width="${width}" height="${height}" rx="18" fill="url(#grad)"/>
<circle cx="${width / 2}" cy="${height / 2}" r="${Math.min(width, height) / 6}" fill="rgba(255,255,255,0.85)"/>
<polygon points="${width / 2 - 10},${height / 2 - 14} ${width / 2 - 10},${height / 2 + 14} ${width / 2 + 16},${height / 2}" fill="#0f172a"/>
<text x="50%" y="${height - 18}" font-family="Arial, sans-serif" font-size="16" fill="rgba(255,255,255,0.9)" text-anchor="middle">
VIDEO
</text>
</svg>
`;
await sharp(Buffer.from(svg))
.resize(width, height, { fit: 'cover' })
.jpeg({ quality: settings.quality || DEFAULT_THUMBNAIL_QUALITY })
.toFile(thumbnailPath);
return path.relative(getStoragePath(), thumbnailPath);
} catch (error) {
logger.error('Failed to generate video placeholder thumbnail:', error.message);
return null;
}
}
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail, generateVideoPlaceholder };
+184 -18
View File
@@ -3,13 +3,63 @@ const fs = require('fs').promises;
const { db } = require('../database/db');
const { generateThumbnail } = require('./imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { processUploadedVideo, isVideoMimeType } = require('./videoProcessor');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
function normalizeFiles(files) {
// Handle null, undefined, or falsy values
if (!files) {
console.log('[normalizeFiles] No files provided');
return [];
}
// Handle arrays
if (Array.isArray(files)) {
const validFiles = files.filter(Boolean);
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from array`);
return validFiles;
}
// Handle iterable objects (some multer configurations)
try {
if (typeof files === 'object' && typeof files[Symbol.iterator] === 'function') {
const validFiles = Array.from(files).filter(Boolean);
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from iterable`);
return validFiles;
}
} catch (err) {
console.warn('[normalizeFiles] Failed to iterate files object:', err.message);
}
// Handle plain objects (multer fieldname mapping)
if (typeof files === 'object') {
try {
const validFiles = Object.values(files)
.flatMap((value) => (Array.isArray(value) ? value : [value]))
.filter(Boolean);
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from object`);
return validFiles;
} catch (err) {
console.warn('[normalizeFiles] Failed to process files object:', err.message);
return [];
}
}
// Unexpected type
console.warn('[normalizeFiles] Unexpected files type:', typeof files);
return [];
}
async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categoryId = null) {
const uploadedPhotos = [];
const fileList = normalizeFiles(files);
if (fileList.length === 0) {
return uploadedPhotos;
}
// Get event details
const event = await db('events').where({ id: eventId }).first();
if (!event) {
@@ -17,7 +67,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
}
// Process each file
for (const file of files) {
for (const file of fileList) {
const trx = await db.transaction();
try {
@@ -35,8 +85,9 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
.where({ event_id: eventId, type: photoType })
.count('id as count')
.first();
counter = (existingCount.count || 0) + 1;
const existingCountValue = Number(existingCount?.count ?? 0);
counter = existingCountValue + 1;
// Generate new filename
const extension = path.extname(file.originalname);
@@ -53,28 +104,125 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
await fs.mkdir(destPath, { recursive: true });
const newPath = path.join(destPath, newFilename);
const tempPath = file?.path || file?.filepath || file?.tempFilePath;
if (!tempPath) {
const fileInfo = JSON.stringify({
originalname: file?.originalname,
mimetype: file?.mimetype,
size: file?.size,
availableKeys: Object.keys(file || {})
});
throw new Error(`Uploaded file is missing a temporary path. File info: ${fileInfo}`);
}
// Verify temp file exists before copying
try {
await fs.access(tempPath);
} catch (accessErr) {
console.error(`Temp file not accessible: ${tempPath}`, {
originalname: file?.originalname,
error: accessErr.message
});
throw new Error(`Uploaded file not found at temporary location: ${tempPath}`);
}
// Use copyFile and unlink instead of rename to avoid cross-device issues
await fs.copyFile(file.path, newPath);
await fs.unlink(file.path);
// Generate thumbnail
const thumbnailPath = await generateThumbnail(newPath);
try {
await fs.copyFile(tempPath, newPath);
console.log(`Successfully copied ${file.originalname} to ${newPath}`);
} catch (copyErr) {
console.error(`Failed to copy file from ${tempPath} to ${newPath}:`, copyErr);
throw new Error(`Failed to copy uploaded file: ${copyErr.message}`);
} finally {
// Clean up temp file with better error handling
try {
await fs.unlink(tempPath);
console.log(`Cleaned up temp file: ${tempPath}`);
} catch (unlinkErr) {
// Only warn if file exists but couldn't be deleted
// ENOENT means file was already deleted, which is fine
if (unlinkErr?.code !== 'ENOENT') {
console.warn(`Failed to clean up temp upload ${tempPath}:`, {
error: unlinkErr.message,
code: unlinkErr.code
});
}
}
}
// Determine if this is a video or image
const isVideo = isVideoMimeType(file.mimetype);
const mediaType = isVideo ? 'video' : 'image';
// Generate thumbnail and extract metadata
let thumbnailPath;
let videoMetadata = null;
if (isVideo) {
// 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_${newFilename.replace(/\.[^.]+$/, '.jpg')}`);
const result = await processUploadedVideo(newPath, videoThumbnailPath);
videoMetadata = result.metadata;
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
} else {
// Process image: generate thumbnail
thumbnailPath = await generateThumbnail(newPath);
}
// Calculate relative paths
const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), newPath);
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
// Add to database with uploaded_by field
const [photoId] = await trx('photos').insert({
// Add to database with uploaded_by field and media metadata
let insertResult;
const clientName = trx?.client?.config?.client;
const supportsReturning = ['pg', 'postgres', 'postgresql'].includes(clientName);
const photoData = {
event_id: eventId,
filename: newFilename,
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: file.size
});
size_bytes: file.size,
uploaded_by: uploadedBy,
source_origin: 'managed',
media_type: mediaType,
mime_type: file.mimetype
};
// Add video-specific metadata if applicable
if (isVideo && videoMetadata) {
photoData.duration = videoMetadata.duration;
photoData.video_codec = videoMetadata.videoCodec;
photoData.audio_codec = videoMetadata.audioCodec;
photoData.width = videoMetadata.width;
photoData.height = videoMetadata.height;
}
if (supportsReturning) {
insertResult = await trx('photos')
.insert(photoData)
.returning('id');
} else {
insertResult = await trx('photos').insert(photoData);
}
const insertedId = Array.isArray(insertResult)
? (insertResult[0]?.id ?? insertResult[0])
: insertResult;
const photoId = typeof insertedId === 'object' ? insertedId.id : insertedId;
if (photoId === undefined || photoId === null) {
throw new Error('Failed to determine inserted photo ID');
}
// Commit transaction
await trx.commit();
@@ -84,10 +232,28 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
size: file.size,
type: photoType
});
console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`);
} catch (error) {
console.error(`Error processing file ${file.originalname}:`, error);
if (trx) await trx.rollback();
console.error(`Error processing file ${file.originalname}:`, {
error: error.message,
stack: error.stack,
originalname: file.originalname,
mimetype: file.mimetype,
size: file.size,
tempPath: file?.path || file?.filepath || file?.tempFilePath
});
if (trx) {
try {
await trx.rollback();
} catch (rollbackErr) {
console.error('Failed to rollback transaction:', rollbackErr);
}
}
// Continue with other files
// Note: Individual file failures don't stop the entire upload batch
}
}
@@ -96,4 +262,4 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
module.exports = {
processUploadedPhotos
};
};
+54
View File
@@ -0,0 +1,54 @@
const path = require('path');
const { resolveExternalPath } = require('./externalMediaService');
const { safePathJoin } = require('../utils/fileSecurityUtils');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/**
* Resolve absolute photo file path based on event + photo origin
* Managed: storage/events/active + photo.path (legacy variants supported)
* External reference: EXTERNAL_MEDIA_ROOT + event.external_path + photo.external_relpath
*/
function resolvePhotoFilePath(event, photo) {
if (!event || !photo) throw new Error('resolvePhotoFilePath requires event and photo');
// IMPORTANT: photo.source_origin takes precedence over event.source_mode
// This allows events in "reference" mode to have mixed sources:
// - Imported photos: source_origin = 'external'
// - Uploaded photos: source_origin = 'managed'
const mode = (photo.source_origin || event.source_mode || 'managed');
if (mode === 'reference' || mode === 'external') {
if (!photo.external_relpath) {
throw new Error('Missing external_relpath for external photo');
}
// Normalize duplicate leaf segments (e.g., event.external_path ends with 'individual'
// and external_relpath starts with 'individual/') to avoid double segment like
// '/external-media/.../individual/individual/file.jpg'
let rel = photo.external_relpath;
try {
const lastSeg = path.basename(event.external_path || '');
const firstSeg = rel.split(path.sep)[0];
if (lastSeg && firstSeg && lastSeg === firstSeg) {
rel = rel.split(path.sep).slice(1).join(path.sep) || '';
}
} catch (_) {
// ignore normalization errors
}
return resolveExternalPath(event, rel);
}
const storagePath = getStoragePath();
const eventsRoot = path.join(storagePath, 'events/active');
if (photo.path && photo.path.startsWith('events/active/')) {
// Legacy paths already include prefix; normalize via safe join
return safePathJoin(storagePath, photo.path.replace(/^events\/active\/?/, 'events/active/'));
}
const relativeSegment = photo.path ? photo.path.replace(/^\/+/, '') : '';
return safePathJoin(eventsRoot, relativeSegment);
}
module.exports = {
resolvePhotoFilePath,
};
+262
View File
@@ -0,0 +1,262 @@
const crypto = require('crypto');
const sanitizeHtml = require('sanitize-html');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { sanitizeCss } = require('../utils/cssSanitizer');
const {
DEFAULT_PUBLIC_SITE_TITLE,
DEFAULT_PUBLIC_SITE_HTML,
DEFAULT_PUBLIC_SITE_CSS,
} = require('../constants/publicSiteDefaults');
const CACHE_TTL_MS = Number(process.env.PUBLIC_SITE_CACHE_TTL_MS || 60_000);
let cachedPayload = null;
let cacheExpiresAt = 0;
const ALLOWED_HTML_TAGS = [
'a', 'article', 'aside', 'blockquote', 'br', 'button', 'caption', 'div',
'em', 'figure', 'figcaption', 'footer', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'header', 'hr', 'img', 'li', 'main', 'nav', 'ol', 'p', 'section', 'span',
'strong', 'sup', 'sub', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead', 'tr',
'ul'
];
const COMMON_ATTRIBUTES = ['class', 'id', 'role', 'aria-label', 'aria-hidden'];
function parseSettingValue(value) {
if (value === null || value === undefined) {
return null;
}
try {
return JSON.parse(value);
} catch (error) {
return value;
}
}
async function fetchPublicSiteSettings() {
const rows = await db('app_settings')
.whereIn('setting_key', [
'general_public_site_enabled',
'general_public_site_html',
'general_public_site_custom_css'
]);
const map = {
general_public_site_enabled: false,
general_public_site_html: DEFAULT_PUBLIC_SITE_HTML,
general_public_site_custom_css: ''
};
rows.forEach((row) => {
const parsed = parseSettingValue(row.setting_value);
map[row.setting_key] = parsed == null ? map[row.setting_key] : parsed;
});
return map;
}
function sanitizeBrandUrl(url) {
if (typeof url !== 'string' || !url.trim()) {
return null;
}
const trimmed = url.trim();
if (trimmed.startsWith('javascript:')) {
return null;
}
return trimmed;
}
async function fetchBrandingContext() {
const rows = await db('app_settings')
.whereIn('setting_key', [
'branding_company_name',
'branding_company_tagline',
'branding_support_email',
'branding_logo_url',
'branding_footer_text',
'theme_config'
]);
const context = {
companyName: null,
companyTagline: null,
supportEmail: null,
logoUrl: null,
footerText: null,
colors: {
primary: '#16a34a',
accent: '#0f766e',
background: '#f4fbf6',
text: '#0f172a'
}
};
rows.forEach((row) => {
const parsed = parseSettingValue(row.setting_value);
switch (row.setting_key) {
case 'branding_company_name':
context.companyName = parsed || context.companyName;
break;
case 'branding_company_tagline':
context.companyTagline = parsed || context.companyTagline;
break;
case 'branding_support_email':
context.supportEmail = parsed || context.supportEmail;
break;
case 'branding_logo_url':
context.logoUrl = sanitizeBrandUrl(parsed);
break;
case 'branding_footer_text':
context.footerText = parsed || context.footerText;
break;
case 'theme_config': {
try {
const themeConfig = typeof parsed === 'string' ? JSON.parse(parsed) : parsed;
if (themeConfig && typeof themeConfig === 'object') {
context.colors.primary = themeConfig.primaryColor || context.colors.primary;
context.colors.accent = themeConfig.accentColor || context.colors.accent;
context.colors.background = themeConfig.backgroundColor || context.colors.background;
context.colors.text = themeConfig.textColor || context.colors.text;
}
} catch (error) {
logger.warn('Failed to parse theme configuration for public site', { error: error.message });
}
break;
}
default:
break;
}
});
return context;
}
function sanitizeHtmlPayload(html) {
const sanitized = sanitizeHtml(html || '', {
allowedTags: ALLOWED_HTML_TAGS,
allowedAttributes: {
'*': COMMON_ATTRIBUTES,
a: ['href', 'target', 'rel', ...COMMON_ATTRIBUTES],
img: ['src', 'alt', 'title', 'width', 'height', 'loading', 'decoding', ...COMMON_ATTRIBUTES],
button: ['type', ...COMMON_ATTRIBUTES]
},
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
allowedSchemesByTag: { img: ['http', 'https', 'data'] },
transformTags: {
a: (tagName, attribs) => {
const transformed = { ...attribs };
if (transformed.href && !/^https?:|^mailto:|^tel:/i.test(transformed.href)) {
// sanitize-html will remove disallowed schemes, but we guard as well
delete transformed.href;
}
if (transformed.target === '_blank') {
transformed.rel = transformed.rel ? `${transformed.rel} noopener noreferrer`.trim() : 'noopener noreferrer';
}
return { tagName, attribs: transformed };
}
},
nonBooleanAttributes: ['target'],
parser: {
lowerCaseAttributeNames: true
}
});
return sanitized;
}
function buildCachedPayload(raw) {
const sanitizedHtml = sanitizeHtmlPayload(raw.publicSite.general_public_site_html || DEFAULT_PUBLIC_SITE_HTML);
const sanitizedCss = sanitizeCss(raw.publicSite.general_public_site_custom_css || '');
const enabled = Boolean(raw.publicSite.general_public_site_enabled);
const title = raw.branding.companyName || DEFAULT_PUBLIC_SITE_TITLE;
const baseCss = sanitizeCss(DEFAULT_PUBLIC_SITE_CSS);
const substitutedHtml = applyBrandTokens(sanitizedHtml, raw.branding);
const hash = crypto
.createHash('sha1')
.update(`${enabled}|${substitutedHtml}|${sanitizedCss}|${baseCss}|${JSON.stringify(raw.branding)}`)
.digest('hex');
return {
enabled,
html: substitutedHtml,
css: sanitizedCss,
baseCss,
title,
branding: raw.branding,
etag: `W/"${hash}"`
};
}
async function getPublicSitePayload({ bypassCache = false } = {}) {
if (!bypassCache && cachedPayload && Date.now() < cacheExpiresAt) {
return cachedPayload;
}
const [publicSite, branding] = await Promise.all([
fetchPublicSiteSettings(),
fetchBrandingContext()
]);
const payload = buildCachedPayload({ publicSite, branding });
cachedPayload = payload;
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
return payload;
}
function clearPublicSiteCache() {
cachedPayload = null;
cacheExpiresAt = 0;
}
async function getDefaultPublicSitePayload() {
const branding = await fetchBrandingContext();
return buildCachedPayload({
publicSite: {
general_public_site_enabled: false,
general_public_site_html: DEFAULT_PUBLIC_SITE_HTML,
general_public_site_custom_css: ''
},
branding
});
}
async function getRawPublicSiteSettings() {
return fetchPublicSiteSettings();
}
function applyBrandTokens(html, branding) {
if (!html) {
return html;
}
const tokens = {
company_name: branding.companyName || '',
company_tagline: branding.companyTagline || '',
support_email: branding.supportEmail || '',
brand_logo_url: branding.logoUrl || '/picpeak-logo-transparent.png',
brand_primary_hex: branding.colors?.primary || '#2563eb',
brand_accent_hex: branding.colors?.accent || '#1d4ed8',
brand_background_hex: branding.colors?.background || '#f8fafc',
brand_text_hex: branding.colors?.text || '#0f172a'
};
return html.replace(/\{\{\s*(company_name|company_tagline|support_email|brand_logo_url|brand_primary_hex|brand_accent_hex|brand_background_hex|brand_text_hex)\s*\}\}/gi,
(_, key) => tokens[key] || '');
}
module.exports = {
getPublicSitePayload,
clearPublicSiteCache,
getDefaultPublicSitePayload,
getRawPublicSiteSettings
};
+5 -7
View File
@@ -2,6 +2,7 @@ const rateLimit = require('express-rate-limit');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const logger = require('../utils/logger');
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
// Cache for rate limit settings
let settingsCache = null;
@@ -95,12 +96,9 @@ function clearSettingsCache() {
*/
function isAuthenticated(req) {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return false;
}
const token = authHeader.substring(7);
const slugMatch = req.path.match(/\/api\/(?:gallery|secure-images)\/([^\/]+)/);
const slug = slugMatch ? slugMatch[1] : req.requestedSlug;
const token = getAdminTokenFromRequest(req) || getGalleryTokenFromRequest(req, slug);
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Check if token is valid
@@ -280,4 +278,4 @@ module.exports = {
createAuthRateLimiter,
isAuthenticated,
shouldSkipRateLimit
};
};
+181
View File
@@ -0,0 +1,181 @@
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { extractShareToken, isPotentialShareToken, buildSharePath } = require('../utils/shareLinkUtils');
const SETTING_KEY = 'general_short_gallery_urls';
const CACHE_TTL_MS = 60_000;
let cachedSetting = null;
let cacheExpiresAt = 0;
const parseSettingValue = (rawValue) => {
if (rawValue === undefined || rawValue === null) {
return null;
}
if (typeof rawValue === 'boolean') {
return rawValue;
}
if (typeof rawValue === 'number') {
return rawValue !== 0;
}
if (typeof rawValue === 'string') {
const trimmed = rawValue.trim();
if (!trimmed) {
return null;
}
try {
const parsed = JSON.parse(trimmed);
return parseSettingValue(parsed);
} catch {
const normalized = trimmed.toLowerCase();
if (normalized === 'true' || normalized === '1' || normalized === 'yes') {
return true;
}
if (normalized === 'false' || normalized === '0' || normalized === 'no') {
return false;
}
return null;
}
}
if (typeof rawValue === 'object') {
try {
return parseSettingValue(JSON.parse(JSON.stringify(rawValue)));
} catch {
return null;
}
}
return null;
};
const getRawSettingValue = async () => {
try {
const setting = await db('app_settings').where({ setting_key: SETTING_KEY }).first();
return setting?.setting_value ?? null;
} catch (error) {
console.error('Failed to read gallery URL setting:', error.message);
return null;
}
};
const isShortGalleryUrlsEnabled = async () => {
if (cachedSetting !== null && Date.now() < cacheExpiresAt) {
return cachedSetting;
}
const rawValue = await getRawSettingValue();
const parsed = parseSettingValue(rawValue);
cachedSetting = parsed === null ? false : Boolean(parsed);
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
return cachedSetting;
};
const clearShareLinkSettingsCache = () => {
cachedSetting = null;
cacheExpiresAt = 0;
};
const buildShareLinkVariants = async ({ slug, shareToken }) => {
if (!shareToken) {
throw new Error('shareToken is required to build share link variants');
}
const shortEnabled = await isShortGalleryUrlsEnabled();
const sharePath = buildSharePath(slug, shareToken, shortEnabled);
const frontendBase = (process.env.FRONTEND_URL || '').replace(/\/$/, '');
const shareUrl = frontendBase ? `${frontendBase}${sharePath}` : sharePath;
return {
shortEnabled,
sharePath,
shareUrl,
shareLinkToStore: sharePath
};
};
const getEventShareToken = (event) => {
if (!event) {
return null;
}
if (event.share_token) {
return event.share_token;
}
return extractShareToken(event.share_link);
};
const ACTIVE_EVENT_FILTER = {
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
};
const resolveShareIdentifier = async (identifier) => {
if (!identifier) {
return null;
}
const trimmed = String(identifier).trim();
if (!trimmed) {
return null;
}
const baseQuery = db('events')
.select(
'id',
'slug',
'share_link',
'share_token',
'require_password',
'event_name',
'event_type',
'event_date',
'expires_at',
'is_active',
'is_archived'
)
.where(ACTIVE_EVENT_FILTER);
let event = await baseQuery.clone().where({ slug: trimmed }).first();
if (event) {
return { event, matchType: 'slug', shareToken: getEventShareToken(event) };
}
event = await baseQuery.clone().where({ share_token: trimmed }).first();
if (event) {
return { event, matchType: 'token', shareToken: getEventShareToken(event) };
}
event = await baseQuery.clone().where({ share_link: trimmed }).first();
if (event) {
return { event, matchType: 'link', shareToken: getEventShareToken(event) };
}
event = await baseQuery.clone().where('share_link', 'like', `%/${trimmed}`).first();
if (event) {
return { event, matchType: 'link_partial', shareToken: getEventShareToken(event) };
}
// As a final fallback, if identifier looks like a token but we did not match via share_token
if (isPotentialShareToken(trimmed)) {
event = await baseQuery.clone().whereRaw('LOWER(share_token) = ?', [trimmed.toLowerCase()]).first();
if (event) {
return { event, matchType: 'token_case_insensitive', shareToken: getEventShareToken(event) };
}
}
return null;
};
module.exports = {
isShortGalleryUrlsEnabled,
clearShareLinkSettingsCache,
buildShareLinkVariants,
getEventShareToken,
resolveShareIdentifier
};

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