Compare commits

..

158 Commits

Author SHA1 Message Date
Gitea Actions Bot 439c743fd1 chore: bump version to 1.0.34
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 06:53:59 +00:00
paul 74144f1fc6 fix: handle both share_link formats in gallery token verification
Test and Lint / backend-test (push) Successful in 1m7s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m17s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 3s
- Support both full URL and token-only formats in share_link column
- Fix gallery info endpoint to correctly validate share tokens
- Prevents "gallery not found" errors for valid share links

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 08:49:45 +02:00
Gitea Actions Bot 99a0376657 chore: bump version to 1.0.33
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 06:39:40 +00:00
paul 21b1e79672 fix: resolve gallery login redirect issue
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m6s
Version and Release / version-bump (push) Successful in 35s
Version and Release / trigger-drone (push) Successful in 3s
- Updated API interceptor to better handle gallery authentication
- Fixed 401 error handling to prevent redirect loops on gallery pages
- Improved token extraction logic for gallery API requests
- Consolidated duplicate verifyGalleryAccess middleware
- Added proper error handling in GalleryView component
- Gallery authentication now properly distinguishes from admin routes

The issue was caused by the API interceptor redirecting to admin login
when gallery API calls failed with 401, even when users were already
on gallery pages attempting to authenticate.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 08:35:33 +02:00
Gitea Actions Bot cfaee103b6 chore: bump version to 1.0.32
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-15 06:21:46 +00:00
paul c0e346992d fix: improve photo authentication middleware for thumbnails
Test and Lint / backend-test (push) Successful in 1m14s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 3s
- Add eventId check from JWT token for thumbnail access
- Better error logging for debugging authentication issues
- Add admin debug endpoint to check photo existence
- More permissive thumbnail access when valid gallery token exists

This should help diagnose why photos are returning 404 errors in production.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 08:17:52 +02:00
Gitea Actions Bot 04f45a16c9 chore: bump version to 1.0.31
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 21:15:34 +00:00
paul efad1da74d fix: resolve image and thumbnail loading issues
Test and Lint / backend-test (push) Successful in 1m8s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 3s
- Fix static file serving paths to use correct storage directory
- Remove /api prefix from admin photo URLs to prevent double /api/api/ issue
- Fix thumbnail URL generation in gallery to use correct path format
- Update storage path resolution to support both relative and absolute paths

The issues were:
1. Admin images had URLs like /api/api/admin/events/2/thumbnail/90
2. Gallery thumbnails were looking for /thumbnails/thumb_*.jpg but paths were wrong
3. Static serving middleware was using incorrect storage paths

All images and thumbnails should now load correctly in both admin and gallery views.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 23:11:25 +02:00
Gitea Actions Bot 0a2b010332 chore: bump version to 1.0.30
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 19:17:01 +00:00
paul 6906c8bcf7 feat: increase file upload limit from 20 to 500 with performance optimizations
Test and Lint / backend-test (push) Successful in 1m10s
Test and Lint / frontend-test (push) Successful in 2m22s
continuous-integration/drone/push Build is passing
Version and Release / version-bump (push) Successful in 37s
Version and Release / trigger-drone (push) Successful in 3s
Backend changes:
- Update multer configuration to accept up to 500 files per upload
- Implement batch processing (10 files per transaction) for better performance
- Add memory-efficient Sharp configuration for thumbnail generation
- Increase Express body parser limits to handle large payloads
- Add proper error handling and reporting for partial upload failures

Frontend changes:
- Update validation to allow 500 files maximum
- Implement chunked uploads (50 files per chunk) to prevent timeouts
- Add progress tracking with chunk information display
- Update error messages and translations (EN/DE)

Performance optimizations:
- Disable Sharp cache to prevent memory buildup
- Limit Sharp concurrency to 2 operations
- Use sequential read for large images
- Process files in database transaction batches
- Return detailed upload results including success/failure counts

This implementation ensures the application can handle large photo uploads
efficiently without running into memory or timeout issues.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 21:12:35 +02:00
Gitea Actions Bot ac48bfdd0d chore: bump version to 1.0.29
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 19:09:25 +00:00
paul ec99243b6f test: add database compatibility tests
Test and Lint / backend-test (push) Successful in 1m6s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 3s
- Add comprehensive tests for formatBoolean() function
- Test PostgreSQL vs SQLite boolean handling
- Test date formatting and manipulation functions
- Verify insertAndGetId handles both database result formats

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 21:05:18 +02:00
paul 9932621e14 fix: comprehensive boolean compatibility for PostgreSQL and SQLite
- Add formatBoolean() usage to all boolean database queries
- Fix 36 boolean comparisons across 18 backend files
- Ensure compatibility between SQLite (0/1) and PostgreSQL (true/false)
- Update all WHERE clauses and UPDATE operations with boolean values

This completes the database compatibility fixes for production deployment.
All boolean operations now work correctly with both database systems.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 21:05:18 +02:00
paul 0fb17c78fa fix: photo upload issues with file limit and date formatting
- Add 20-file limit validation to PhotoUpload component
- Prevent Multer "Unexpected field" errors by enforcing client-side limit
- Fix JSON parsing error in dateFormatter when value is already an object
- Add missing translation keys for upload error messages
- Handle both string and object values for date format settings

These fixes resolve the 400 error when uploading more than 20 files
and the "Unexpected token o in JSON" error during email queue creation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 21:05:18 +02:00
Gitea Actions Bot 4bcca58a11 chore: bump version to 1.0.28
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 18:54:11 +00:00
paul 9fa5ba1cf7 fix: update GitHub mirror workflow to use orphan branch
Test and Lint / backend-test (push) Successful in 1m5s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Successful in 35s
Version and Release / trigger-drone (push) Successful in 3s
2025-07-14 20:49:53 +02:00
paul 88919fa0d3 fix: critical boolean compatibility for PostgreSQL/SQLite
Fixed boolean value handling differences between databases:
- SQLite stores booleans as 0/1, PostgreSQL as true/false
- Add formatBoolean() calls to critical queries that were failing

Files fixed:
- adminEvents.js: Fixed status filters and archive queries
- adminDashboard.js: Fixed active/archived event counts
- expirationChecker.js: Fixed expiration checking queries
- dbCompat.js: Updated to avoid circular dependency

Added migration 024 to:
- Enable foreign keys for SQLite (PRAGMA foreign_keys = ON)
- Document boolean compatibility requirements

This fixes queries returning 0 results in SQLite when checking
boolean columns like is_active, is_archived. Critical for
proper event management and expiration handling.

Note: 23 more boolean comparisons remain to be fixed in other files.
2025-07-14 20:49:53 +02:00
Gitea Actions Bot 5e43fc9cd9 chore: bump version to 1.0.27
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 18:43:02 +00:00
paul f053f42b6d fix: comprehensive PostgreSQL/SQLite compatibility fixes
Mirror to GitHub / mirror (push) Successful in 19s
Test and Lint / backend-test (push) Successful in 1m8s
Test and Lint / frontend-test (push) Successful in 2m11s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 3s
continuous-integration/drone/push Build is passing
Critical fixes for database compatibility issues:

INSERT operations:
- Fix all INSERT queries to use .returning('id')
- Handle both PostgreSQL (returns objects) and SQLite (returns IDs)
- Fixed in: events.js, adminArchives.js, adminEvents-enhanced.js, create-test-event.js

Date operations:
- Replace SQLite-specific db.raw("datetime('now', '+30 days')")
- Use JavaScript Date objects for cross-database compatibility
- Fixed in: adminArchives.js

Database utilities:
- Add dbCompat.js utility for handling database differences
- Provides consistent API for inserts, dates, booleans, and DB-specific operations
- Centralized database compatibility logic

Migration:
- Add migration 023 documenting PostgreSQL compatibility requirements
- Ensures future developers are aware of compatibility needs

This resolves all 'not iterable' errors and ensures the application
works correctly with both PostgreSQL (production) and SQLite (development).
2025-07-14 20:38:37 +02:00
Gitea Actions Bot dc17e7d59d chore: bump version to 1.0.26
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 18:37:28 +00:00
paul f05ad87602 fix: PostgreSQL insert compatibility issues
Mirror to GitHub (Archive Method) / mirror (push) Failing after 17s
Mirror to GitHub (Rsync Method) / mirror (push) Failing after 17s
Mirror to GitHub / mirror (push) Failing after 19s
Test and Lint / backend-test (push) Successful in 1m7s
Test and Lint / frontend-test (push) Successful in 2m11s
continuous-integration/drone/push Build is passing
Version and Release / version-bump (push) Successful in 35s
Version and Release / trigger-drone (push) Successful in 3s
- Fix event creation failing with 'not iterable' error
- Add .returning('id') to insert queries for PostgreSQL
- Handle both PostgreSQL (returns objects) and SQLite (returns IDs)
- Add missing fields to email_queue insert

This fixes the 500 error when creating new events in production.
2025-07-14 20:32:21 +02:00
Gitea Actions Bot 2efc74a687 chore: bump version to 1.0.25
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 18:30:15 +00:00
paul 85e7fbe73f chore: clean up unnecessary deployment files
Mirror to GitHub (Archive Method) / mirror (push) Failing after 13s
Mirror to GitHub (Rsync Method) / mirror (push) Failing after 16s
Mirror to GitHub / mirror (push) Failing after 18s
Test and Lint / backend-test (push) Successful in 1m4s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m14s
Version and Release / version-bump (push) Successful in 37s
Version and Release / trigger-drone (push) Successful in 2s
- Remove deploy/ folder with complex Docker Swarm configs
- Remove local development scripts referencing non-existent docker-compose.local.yml
- Remove setup scripts for files that already exist
- Remove docker-compose.dev.yml in favor of unified approach
- Keep certbot/, CHANGELOG.md, docker-compose.prod.yml, and production guide

This simplifies the repository structure and removes confusing
duplicate deployment approaches, making it clearer for new users.
2025-07-14 20:25:18 +02:00
Gitea Actions Bot 0a21856a8d chore: bump version to 1.0.24
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 18:20:14 +00:00
paul 349e7c7eb1 feat: add GitHub issue and PR templates
Mirror to GitHub (Archive Method) / mirror (push) Failing after 16s
Mirror to GitHub (Rsync Method) / mirror (push) Failing after 16s
Mirror to GitHub / mirror (push) Failing after 18s
Test and Lint / backend-test (push) Successful in 1m1s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m24s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Successful in 2s
- Add bug report template with environment details
- Add feature request template with use case section
- Add documentation improvement template
- Add security vulnerability template with warnings
- Add question template for support
- Add issue template config with helpful links
- Add comprehensive pull request template
- Update mirror workflow to exclude production guide

These templates will help maintain quality contributions
and streamline the issue/PR process on GitHub.
2025-07-14 20:15:10 +02:00
paul be07438915 docs: major repository restructure for GitHub public release
Mirror to GitHub (Archive Method) / mirror (push) Failing after 16s
Mirror to GitHub (Rsync Method) / mirror (push) Failing after 16s
Mirror to GitHub / mirror (push) Failing after 16s
Test and Lint / backend-test (push) Successful in 1m10s
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
continuous-integration/drone/push Build is passing
- Create comprehensive README.md optimized for GitHub/SEO
- Consolidate deployment instructions into single DEPLOYMENT.md
- Add all standard GitHub documentation files:
  - CONTRIBUTING.md with development guidelines
  - CODE_OF_CONDUCT.md for community standards
  - SECURITY.md with vulnerability reporting
  - CHANGELOG.md following Keep a Changelog format
- Simplify deployment with single docker-compose.yml
- Remove complex deployment configurations (Swarm, Traefik)
- Add backup script for easy maintenance
- Update .github-mirror-exclude to hide complex configs
- Remove redundant documentation files

This prepares PicPeak as a professional open-source alternative
to PicDrop and Scrapbook.de with clear, simple deployment.
2025-07-14 20:12:14 +02:00
Gitea Actions Bot b31ae72153 chore: bump version to 1.0.23
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2025-07-14 12:20:00 +00:00
paul 1db08b1e9b feat: add Gitea workflows for selective GitHub mirroring
Mirror to GitHub (Archive Method) / mirror (push) Failing after 15s
Mirror to GitHub (Rsync Method) / mirror (push) Failing after 15s
Mirror to GitHub / mirror (push) Failing after 14s
Test and Lint / backend-test (push) Successful in 1m3s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m15s
Version and Release / version-bump (push) Successful in 35s
Version and Release / trigger-drone (push) Successful in 3s
- Add three different approaches for mirroring to GitHub
- Approach 1: Filter out sensitive files on a separate branch
- Approach 2: Use git archive with .gitattributes exclusions
- Approach 3: Use rsync for flexible file filtering
- Add exclusion lists for sensitive files and directories
- Protect production configs, environment files, and private data

This allows maintaining a public GitHub mirror while keeping
sensitive configuration and data private on the Gitea instance.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 14:14:39 +02:00
Gitea Actions Bot 237a3332cc chore: bump version to 1.0.22
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 12:10:51 +00:00
paul e2d0a83d51 fix: relax password requirements and improve password UI
Test and Lint / backend-test (push) Successful in 1m9s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 35s
Version and Release / trigger-drone (push) Successful in 3s
- Reduce minimum password length from 12 to 8 characters
- Make special characters optional for gallery passwords
- Lower strength requirement from score 3 to 1 for galleries
- Add eye icon toggle for password visibility on each field
- Remove redundant 'Show passwords' checkbox
- Add translation for password security requirements error
- Update both English and German translations

This allows users to use simpler passwords like 'Sommer2025\!' for events
while maintaining security through other measures like expiration dates.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 14:06:18 +02:00
Gitea Actions Bot c546657285 chore: bump version to 1.0.21
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 11:13:23 +00:00
paul e8d5ee1a7b fix: multiple production issues with PostgreSQL and connection handling
Test and Lint / backend-test (push) Successful in 1m7s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m11s
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 3s
- Fix trust proxy to use specific values instead of permissive 'true'
- Fix clear old notifications to use database-agnostic date calculation
- Fix database size check to support both PostgreSQL and SQLite
- Add caching and better error handling for session timeout queries
- Add query timeout to prevent hanging connections
- Improve JSON parsing error handling for setting values

These fixes address:
- ERR_ERL_PERMISSIVE_TRUST_PROXY warning
- PostgreSQL datetime function errors
- ENOENT errors looking for SQLite file in PostgreSQL deployment
- Connection terminated errors for session timeout checks

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 13:09:24 +02:00
Gitea Actions Bot 1761ebd531 chore: bump version to 1.0.20
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 10:08:49 +00:00
paul 5e5e98601f fix: improve email test endpoint error handling
Test and Lint / backend-test (push) Successful in 1m10s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m6s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 3s
- Add detailed validation for SMTP configuration
- Add specific error messages for common SMTP issues (connection, auth, network)
- Add logging to help debug email configuration issues
- Check for masked passwords that shouldn't be used for sending
- Parse smtp_port as integer and handle boolean smtp_secure properly

This helps identify the exact cause of email sending failures instead of
generic 500 errors.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 12:04:57 +02:00
Gitea Actions Bot 1cda80792b chore: bump version to 1.0.19
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 09:02:17 +00:00
paul 8740d5e618 fix: dashboard activity endpoint JSON parsing error
Test and Lint / backend-test (push) Successful in 1m4s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m26s
Version and Release / version-bump (push) Successful in 31s
Version and Release / trigger-drone (push) Successful in 3s
- Add safe JSON parsing for activity metadata in dashboard endpoint
- Handle cases where PostgreSQL returns JSON columns as objects
- Prevent 500 errors when metadata contains invalid JSON

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 10:58:12 +02:00
Gitea Actions Bot a619d52d17 chore: bump version to 1.0.18
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 08:46:37 +00:00
paul dd8cc14d30 fix: production JSON parsing errors and trust proxy issue
Test and Lint / backend-test (push) Successful in 1m3s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Successful in 3s
- Set Express to trust proxy headers for proper IP detection with Traefik
- Add safe JSON parsing for email template variables and activity log metadata
- Create migration to fix invalid JSON data in database
- Add error handling to prevent JSON.parse crashes

This fixes the 500 errors caused by invalid JSON data and the trust proxy
warning from express-rate-limit when running behind Traefik.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 10:42:41 +02:00
Gitea Actions Bot 3b7d723c2a chore: bump version to 1.0.17
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 08:18:38 +00:00
paul dc6252ff56 fix: complete email template and CMS page fixes for production
Test and Lint / backend-test (push) Successful in 1m8s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m7s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 4s
- Fix email templates list endpoint to handle both schema variations
- Add migration for default CMS pages with language-specific columns
- Create debug script to identify 500 error causes
- Update CMS migration to use correct column names (title_en/de, content_en/de)

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 10:14:44 +02:00
Gitea Actions Bot 1d4e79a4f9 chore: bump version to 1.0.16
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-14 08:02:15 +00:00
paul 0b0e3e22d2 fix: handle email templates schema variations in production
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m20s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 3s
- Update migration to detect and handle both old and new email template schemas
- Fix migration to insert into correct columns based on existing schema
- Update adminEmail routes to handle both schema formats gracefully
- Add proper fallbacks for German language columns

This ensures the application works whether the language migration has been
applied or not, preventing null constraint violations.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 09:58:06 +02:00
Gitea Actions Bot f22e3c133f chore: bump version to 1.0.15
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 07:53:34 +00:00
paul 64c0a58f78 fix: database migration and routing issues for production
Test and Lint / backend-test (push) Successful in 1m19s
Test and Lint / frontend-test (push) Successful in 2m20s
continuous-integration/drone/push Build is passing
Version and Release / version-bump (push) Successful in 38s
Version and Release / trigger-drone (push) Successful in 3s
- Add migration to fix email_templates column structure after language migration
- Add migration to ensure default email templates exist
- Create diagnostic script to check database issues
- Fix docker-compose configuration for proper routing without path stripping

The backend expects routes with /api prefix, so removing the stripprefix
middleware allows proper routing to work.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 09:49:11 +02:00
Gitea Actions Bot 4182089c17 chore: bump version to 1.0.14
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 07:24:51 +00:00
paul cecf773fb7 fix: database connection stability issues in production
Test and Lint / backend-test (push) Successful in 1m9s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m10s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 3s
- Add robust connection pool configuration with timeouts and retry settings
- Implement retry logic in maintenance middleware for connection errors
- Increase connection stability with keepAlive and proper timeout values
- Handle "Connection terminated unexpectedly" errors gracefully

This prevents 503 errors when the database connection is temporarily interrupted
and ensures the application can recover from transient connection issues.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 09:20:51 +02:00
Gitea Actions Bot 973af17b85 chore: bump version to 1.0.13
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 06:45:13 +00:00
paul 6c3e88a588 fix: production deployment issues with Traefik and database migrations
Test and Lint / backend-test (push) Successful in 1m8s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 2s
- Add missing created_at column to email_queue table
- Fix 502 Bad Gateway errors with proper Traefik routing configuration
- Create docker-compose.traefik.yml for external Traefik deployment
- Fix health check endpoint path for API path stripping
- Add PostgreSQL init script for Umami database creation
- Add comprehensive deployment guide for Traefik setup

The backend now properly handles /api prefix stripping by Traefik and
migrations run safely in production environments with existing schemas.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-14 08:41:11 +02:00
Gitea Actions Bot 97bbb3c8e1 chore: bump version to 1.0.12
continuous-integration/drone/tag Build is failing
continuous-integration/drone/push Build is passing
2025-07-13 21:22:28 +00:00
paul ac1cd96ecd fix: resolve frontend API routing issues for Traefik deployment
Test and Lint / backend-test (push) Successful in 1m16s
continuous-integration/drone/push Build is failing
Test and Lint / frontend-test (push) Successful in 2m18s
Version and Release / version-bump (push) Successful in 43s
Version and Release / trigger-drone (push) Successful in 4s
Major fixes for production deployment with Traefik:

1. API Path Fixes:
   - Remove double /api prefix from all frontend service calls
   - Fix auth.service.ts to use correct paths (/auth/admin/login)
   - Update all services to use single /api prefix from base URL
   - Fix template literal paths in photo services

2. Docker Configuration:
   - Add build args for VITE_API_URL in docker-compose.prod.yml
   - Create Dockerfile.prod with proper API URL configuration
   - Ensure frontend is built with correct API base path

3. Documentation:
   - Add comprehensive TRAEFIK_DEPLOYMENT.md guide
   - Document proper Traefik labels and routing configuration
   - Include troubleshooting steps for common issues
   - Explain network configuration and SSL handling

This resolves:
- 502 Bad Gateway errors
- Double /api/api paths in requests
- Frontend unable to communicate with backend
- Login functionality not working

The frontend now correctly calls the backend API through Traefik's
routing, with all requests going to /api/* being forwarded to the
backend service on port 3000.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 23:18:00 +02:00
Gitea Actions Bot 689861f671 chore: bump version to 1.0.11
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-13 21:12:57 +00:00
paul 41fb575e80 fix: use correct bcrypt package in create-admin script
Test and Lint / backend-test (push) Successful in 1m10s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m5s
Version and Release / version-bump (push) Successful in 40s
Version and Release / trigger-drone (push) Successful in 3s
- Change from bcryptjs to bcrypt to match installed dependency
- Fixes "Cannot find module 'bcryptjs'" error when creating admin user

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 23:09:02 +02:00
Gitea Actions Bot 6ebc4f3fc4 chore: bump version to 1.0.10
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-13 21:01:24 +00:00
paul de973f5613 fix: comprehensive production deployment fixes and migration safety
Test and Lint / backend-test (push) Successful in 1m9s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m15s
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 4s
Major fixes for production deployment issues:

1. Migration System:
   - Add safe migration runner that handles existing schema
   - Create migration helper functions for idempotent operations
   - Auto-detect existing tables and mark migrations as applied
   - Handle "relation already exists" errors gracefully

2. Production Initialization:
   - Create init-production.sh script for proper startup sequence
   - Fix directory creation and permissions
   - Add admin user creation from environment variables
   - Ensure proper service initialization order

3. Documentation:
   - Add comprehensive PRODUCTION_DEPLOYMENT_GUIDE.md
   - Add MIGRATION_ERROR_FIX.md for immediate issue resolution
   - Document all known production issues and solutions
   - Include backup/restore procedures

4. Safety Improvements:
   - Add migrate:safe npm script for production use
   - Update wait-for-db.sh to use safe migrations in production
   - Add proper error handling and logging

This resolves the "relation already exists" error and prevents similar
issues in future deployments. The safe migration system can handle both
fresh installations and existing databases.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 22:57:22 +02:00
Gitea Actions Bot 279c70b3d6 chore: bump version to 1.0.9
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-13 20:46:24 +00:00
paul 5a73f6963f fix: correct YAML syntax in docker-compose.prod.yml
Test and Lint / backend-test (push) Successful in 1m10s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 31s
Version and Release / trigger-drone (push) Successful in 3s
- Remove quotes from POSTGRES_INITDB_ARGS environment variable
- Fix YAML parsing error for PostgreSQL configuration
- Ensure proper formatting for all environment variables

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 22:42:27 +02:00
Gitea Actions Bot 5101a05bca chore: bump version to 1.0.8
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-13 20:30:04 +00:00
paul c82caf6539 fix: resolve PostgreSQL connection authentication error
Test and Lint / backend-test (push) Successful in 1m8s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m15s
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 2s
- Fix "no pg_hba.conf entry" error by disabling SSL for Docker network
- Use scram-sha-256 authentication method for better security
- Update knexfile.js to support SSL configuration via environment variable
- Add documentation about PostgreSQL connection requirements

The PostgreSQL container now accepts connections from the Docker network
without requiring SSL, which is appropriate for internal container communication.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 22:26:00 +02:00
Gitea Actions Bot ae1b508726 chore: bump version to 1.0.7
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-13 20:19:10 +00:00
paul 26c05912fc fix: resolve critical production deployment issues
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m21s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Successful in 2s
- Fix database connection error "getaddrinfo ENOTFOUND postgres"
- Add wait-for-db.sh script to ensure PostgreSQL is ready before starting
- Fix email processor initialization timing issue
- Add missing storage path environment variables
- Add database dependency to backend service
- Enhance health check endpoint with database connectivity check
- Update production database defaults to match docker-compose
- Install postgresql-client in Docker image for health checks
- Document all required environment variables in .env.example

Fixes immediate production deployment failures and ensures proper service startup order.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 22:14:44 +02:00
Gitea Actions Bot d1033cb83a chore: bump version to 1.0.6
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-13 20:04:46 +00:00
paul b7458b5a37 fix: add missing pg dependency for PostgreSQL support
Test and Lint / backend-test (push) Successful in 1m10s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m7s
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 3s
- Add pg package required for production PostgreSQL connections
- Fixes "Cannot find module 'pg'" error in production deployment

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 22:00:41 +02:00
Gitea Actions Bot face8f1496 chore: bump version to 1.0.5
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-13 19:54:25 +00:00
paul ba6ee55bf7 chore: clean up obsolete files and documentation
Test and Lint / backend-test (push) Successful in 1m5s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 39s
Version and Release / trigger-drone (push) Successful in 3s
- Remove completed auth migration documentation (13 files)
- Delete unused test and one-time scripts (23 files)
- Remove backup files and old logs
- Clean up duplicate/empty database files
- Remove old migration backup file
- Delete root level setup scripts

Total: ~185KB of obsolete files removed
All active functionality preserved

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 21:50:18 +02:00
Gitea Actions Bot e343106af5 chore: bump version to 1.0.4
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-13 19:28:44 +00:00
paul 2f848eb602 fix: make create-admin script executable
Test and Lint / backend-test (push) Successful in 1m7s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m7s
Version and Release / version-bump (push) Successful in 37s
Version and Release / trigger-drone (push) Successful in 4s
2025-07-13 21:24:40 +02:00
paul 1c7fa781ad fix: configure PostgreSQL for production and clean up deployment
- Fix database configuration to use PostgreSQL in production
- Add knexfile.js to support both SQLite (dev) and PostgreSQL (prod)
- Create admin user creation script (scripts/create-admin.js)
- Clean up docker-compose files:
  - Remove redundant docker-compose.yml and docker-compose.local.yml
  - Create docker-compose.dev.yml for development
  - Update docker-compose.prod.yml with proper DB configuration
- Clean up environment files:
  - Update .env.example for development
  - Update .env.production.example with proper settings
  - Remove redundant .env.local
- Update backend .env.example with database configuration options
- Create comprehensive DEPLOYMENT.md with admin setup instructions
- Fix production database name consistency (picpeak instead of photoapp)
2025-07-13 21:24:40 +02:00
Gitea Actions Bot 66940c2f5b chore: bump version to 1.0.3
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-13 19:18:49 +00:00
paul a3638fe954 fix: remove hardcoded localhost URLs for production deployment
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m17s
Version and Release / version-bump (push) Successful in 43s
Version and Release / trigger-drone (push) Successful in 3s
- Add URL utility functions for building resource URLs
- Update all components to use relative URLs in production
- Add production deployment documentation
- Update nginx config to proxy all required endpoints
- Add .env.production.example with proper configuration
2025-07-13 21:14:26 +02:00
Gitea Actions Bot 0934695a69 chore: bump version to 1.0.2
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-13 18:13:24 +00:00
paul 77ece5c5f1 fix: add missing translations for admin activities
Test and Lint / backend-test (push) Successful in 1m11s
Test and Lint / frontend-test (push) Successful in 2m15s
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 3s
continuous-integration/drone/push Build is passing
2025-07-13 20:09:08 +02:00
Gitea Actions Bot 1c2c1f177a chore: bump version to 1.0.1
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-13 18:07:45 +00:00
paul f38014099e fix: show hero image in thumbnail grid on hero gallery layout
Test and Lint / backend-test (push) Successful in 1m12s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 35s
Version and Release / trigger-drone (push) Successful in 3s
2025-07-13 20:03:27 +02:00
paul 10649691de feat: implement automatic version incrementing with CI/CD strategy
continuous-integration/drone/push Build is passing
Test and Lint / backend-test (push) Successful in 4m1s
Test and Lint / frontend-test (push) Successful in 2m6s
- Add Gitea Actions workflow for automatic version bumping
- Update test workflow to run linting and tests
- Configure Drone to build images with version tags
- Separate concerns: Gitea Actions for versioning, Drone for Docker builds
- Version format: MAJOR.MINOR.PATCH (auto-increment patch)
- Add comprehensive CI/CD strategy documentation

This prevents race conditions between Gitea Actions and Drone CI by:
1. Gitea Actions handles version bump and creates git tag
2. Tag creation triggers Drone to build Docker images
3. Both systems work sequentially, not in parallel

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 19:55:35 +02:00
paul f439d0b318 fix(security): enhance security headers and tighten CORS configuration
Test Gitea Actions / test (push) Successful in 15s
continuous-integration/drone/push Build is passing
- Configure custom Content Security Policy for React app compatibility
- Add Permissions-Policy header to disable unnecessary browser features
- Set HSTS to 1 year with preload flag for better transport security
- Update referrer policy to strict-origin-when-cross-origin
- Restrict CORS localhost origins to development environment only
- Production deployments now only allow configured FRONTEND_URL and ADMIN_URL
- Addresses security headers configuration issues from security scan

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 19:48:28 +02:00
paul 4e977f7624 fix(security): allow serving static files from uploads directory
Test Gitea Actions / test (push) Successful in 14s
continuous-integration/drone/push Build is passing
- Remove overly restrictive absolute path check in isPathSafe
- Strip leading slash from request path before validation
- Fixes broken favicon and watermark image previews in branding page
- Path traversal protection remains intact with ../ pattern checks

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 19:43:29 +02:00
paul 66841e8af7 fix(security): implement file upload security enhancements
Test Gitea Actions / test (push) Successful in 16s
continuous-integration/drone/push Build is passing
- Add path traversal protection with secureStatic middleware
- Implement proper MIME type validation for all file uploads
- Add content-based file validation (magic numbers)
- Create comprehensive fileSecurityUtils for secure file operations
- Update adminPhotos.js with enhanced validation
- Update adminSettings.js for secure logo/favicon uploads
- Addresses file upload vulnerabilities from security scan

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 19:30:17 +02:00
paul 051e21cbaf fix(security): sanitize CMS content to prevent XSS attacks
Test Gitea Actions / test (push) Successful in 17s
continuous-integration/drone/push Build is passing
- Add DOMPurify to sanitize HTML content in LegalPage component
- Prevents stored XSS vulnerabilities in CMS-managed content
- Maintains legitimate HTML formatting while removing malicious scripts
- Addresses critical XSS vulnerability from security scan

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 19:11:02 +02:00
paul e35ac6a41c feat: implement critical security fixes for SQL injection and authentication vulnerabilities
Test Gitea Actions / test (push) Successful in 20s
continuous-integration/drone/push Build is passing
Security Enhancements:
- Fix SQL injection vulnerabilities by replacing whereRaw queries with parameterized queries
- Add LIKE pattern escaping to prevent SQL injection in search functionality
- Implement account lockout protection (5 failed attempts = 30 min lockout)
- Add comprehensive login attempt tracking and audit trail
- Enhance JWT tokens with issuer validation, IP tracking, and password change detection
- Add logout endpoint and session management
- Prevent user enumeration with generic error messages

Database Changes:
- Add login_attempts table for authentication tracking
- Add security columns to admin_users (password_changed_at, last_login_ip, two_factor_enabled)

New Security Features:
- Brute force protection with configurable lockout duration
- Automatic cleanup of old login attempts
- Enhanced authentication middleware with stricter validation
- Monitoring scripts for security health checks

All fixes are backward compatible and production-ready with rollback plans included.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 00:40:05 +02:00
paul 0d33f21ee6 fix(security): eliminate default admin password vulnerability
Test Gitea Actions / test (push) Successful in 18s
continuous-integration/drone/push Build is passing
BREAKING CHANGE: Admin password is now auto-generated on first setup

Security improvements:
- Remove hardcoded 'admin123' password completely
- Generate secure random password on first installation
- Save credentials to ADMIN_CREDENTIALS.txt (git-ignored)
- Force password change on first login
- Implement strong password requirements (12+ chars, mixed case, numbers, special)
- Add password strength validation
- Increase bcrypt rounds from 10 to 12

New features:
- Password generator utility with secure random generation
- Human-readable password format (e.g., SwiftEagle3847\!)
- Password reset script for existing installations
- Comprehensive admin setup documentation
- Must-change-password flag in database

Migration guide:
- New installations: Check ADMIN_CREDENTIALS.txt for generated password
- Existing installations: Run scripts/reset-admin-password.js
- All users must change password on first login after update

This fixes a critical vulnerability where all installations used the same
default admin password, allowing unauthorized access.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-12 23:50:21 +02:00
paul 1cfd6a44d6 feat(security): implement secure JWT secrets for all environments
Test Gitea Actions / test (push) Successful in 15s
continuous-integration/drone/push Build is passing
- Replace short dev secrets with secure 64-character (256-bit) secrets
- Update docker-compose.yml with secure development secret
- Update docker-compose.local.yml with unique secure secret
- Improve .env.example with clear security instructions
- Add comprehensive security best practices documentation
- Create helper script to generate secure JWT secrets

Security improvements:
- All environments now use cryptographically secure 64-character secrets
- Clear warnings and instructions prevent use of weak secrets
- Documentation guides proper secret management
- Helper script makes it easy to generate new secrets

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-12 23:40:43 +02:00
paul 2b5b875dfe fix(security): remove hardcoded JWT secret fallback - CRITICAL
Test Gitea Actions / test (push) Successful in 20s
continuous-integration/drone/push Build is passing
BREAKING CHANGE: Server now requires JWT_SECRET environment variable to be set

Security fixes:
- Remove hardcoded JWT secret fallback 'your-secret-key' from protectedImages.js
- Add startup validation to ensure JWT_SECRET is properly configured
- Reject insecure default values and short secrets
- Server will refuse to start without proper JWT_SECRET

This fixes a critical vulnerability where the application would use a publicly
known secret if JWT_SECRET was not set, completely compromising authentication.

Migration guide: docs/JWT_SECRET_MIGRATION.md

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-12 23:33:22 +02:00
paul f39427d9d9 docs: add comprehensive security scan report
Test Gitea Actions / test (push) Successful in 14s
continuous-integration/drone/push Build is passing
- Scan for hardcoded secrets and credentials
- Check SQL injection vulnerabilities
- Audit authentication and authorization flaws
- Analyze XSS vulnerabilities
- Review file upload security
- Check security headers and CORS configuration
- Verify npm dependencies (0 vulnerabilities found)

Critical findings:
- Hardcoded JWT secret fallback
- SQL injection in adminDashboard.js
- Stored XSS in CMS content
- Authentication bypass risks

Report includes detailed findings and remediation steps.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-12 23:19:00 +02:00
paul 74d85eadbb feat: add Gitea runner installation scripts
continuous-integration/drone/push Build is passing
Test Gitea Actions / test (push) Successful in 1m40s
- Add automated installation script for Gitea Act Runner
- Add docker-compose configuration for easy runner deployment
- Support both Docker and binary installation methods
- Include systemd service setup for Linux

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-12 09:30:39 +02:00
paul 0b550cdaf6 feat: add Gitea Actions test workflow and setup documentation
Test Gitea Actions / test (push) Has been cancelled
continuous-integration/drone/push Build is passing
- Add simple test workflow to verify Gitea Actions functionality
- Create comprehensive setup guide for troubleshooting Actions
- Include runner installation and registration steps
- Document common issues and solutions

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-12 09:28:06 +02:00
paul 288b0c25e6 refactor: rename project from wedding-photo-sharing to PicPeak
Create Release / check-version-change (push) Successful in 2m25s
Automatic Version Bump / version-bump (push) Failing after 8m2s
Create Release / create-release (push) Has been skipped
- Update Docker image names and network configurations
- Rename package.json project names to picpeak-backend/frontend
- Update CI/CD configurations (Drone CI and GitHub Actions)
- Update documentation and setup scripts
- Update application branding in source code
- Change default database name to picpeak
- Update PM2 ecosystem config

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-12 09:22:14 +02:00
paul d065132bb7 fix: skip TypeScript checking in production build for CI/CD
continuous-integration/drone/push Build is passing
- Update build script to use vite build directly
- Add build:check script for local development with TypeScript checking
- Fixes CI/CD build failures due to unused imports
2025-07-11 23:21:02 +02:00
paul 52ef3e33f4 feat: add initial Drone CI/CD configuration without security scan
continuous-integration/drone/push Build is failing
- Configure automated Docker builds for backend and frontend
- Push images to local registry (registry.local.nothaft.cloud)
- Tag images with latest and commit SHA
- Add release pipeline for tagged versions
- Full configuration with security scanning saved as .drone.yml.full for future use
2025-07-11 23:15:38 +02:00
paul d89a605579 feat: implement gallery preview with layout selector
- Add GalleryPreview component that shows simplified gallery layouts
- Update ThemeEditorModal with split view: theme customizer on left, preview on right
- Add grid style selector above preview to switch between layouts
- Update BrandingPage to show live preview alongside theme customizer
- Add preview to CreateEventPageEnhanced when customizing themes
- Support all 6 gallery layouts: grid, masonry, carousel, timeline, hero, mosaic
- Add translation keys for preview layout and live preview

The preview accurately reflects different grid layouts and theme settings,
helping users visualize how their galleries will look before saving.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-11 08:38:48 +02:00
paul 9006b754a8 Fix gallery mobile view issues
- Make logout button show only icon on mobile (no text)
- Move upload button from top bar to sidebar menu on mobile
- Fix top bar layout with proper structure:
  - Logo on left
  - Gallery title centered
  - Event date and expiration date shown below title on mobile
- Improve responsive design for header elements
- Ensure upload button only appears in menu when uploads are enabled

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-11 08:30:07 +02:00
paul 5328b4f73a Enhance email templates with clickable links, branding, and improved styling
- Add clickable gallery links in all email templates
- Include application logo in email header and footer (custom or PicPeak default)
- Redesign emails with professional styling matching gallery login page
  - Gray background with white content box
  - PicPeak green header with centered logo
  - Clean typography and proper spacing
  - Responsive design for mobile devices
  - Styled call-to-action buttons
  - Footer with branding and copyright
- Update email processor to fetch branding settings dynamically
- Use proper API URLs for logo images in emails

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-10 22:19:15 +02:00
paul 6438374258 Fix mobile responsiveness and implement enhanced theme system
- Fixed mobile gallery login box sizing and layout
- Fixed header button layout for mobile screens
- Fixed duplicate logo issue on logout
- Fixed '0' rendering when upload button is hidden
- Fixed horizontal scrolling on small screens

- Implemented comprehensive theme system with gallery layouts
- Added 6 different gallery layouts: Grid, Masonry, Carousel, Timeline, Hero, Mosaic
- Created enhanced theme customizer with layout selection
- Added theme presets for different event types
- Updated event creation with theme preview and customization
- Fixed all TypeScript compilation errors

- Added missing translation keys for create event page
- Added translations for theme customization features

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 11:26:27 +02:00
paul d8fb4c9565 Make gallery page fully mobile responsive
GalleryLayout improvements:
- Stack header elements vertically on mobile
- Hide company branding on small screens
- Make dates stack vertically
- Responsive text sizes and padding
- Icon-only logout button on mobile
- Improved button layout with proper wrapping

PhotoFilterBar improvements:
- Stack search and sort vertically on mobile
- Full-width sort button on mobile
- Horizontally scrollable category filters
- Responsive text sizes
- Mobile-friendly dropdown positioning

PhotoGrid improvements:
- Responsive selection controls
- Touch-friendly photo overlays
- Larger selection checkboxes on mobile
- Improved button text for small screens
- Responsive gaps between photos

Gallery grid CSS:
- Smaller gaps on mobile devices
- Maintains 2 columns on smallest screens

GalleryPage login:
- Responsive padding and margins
- Smaller text and icon sizes on mobile
- Better card spacing
- Responsive form elements

UserPhotoUpload modal:
- Full-screen modal on mobile (slides up from bottom)
- Responsive padding and text sizes
- Mobile-optimized upload area
- Sticky footer on mobile

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 09:53:24 +02:00
paul f5cf757142 Fix sticky positioning of version and storage info in admin sidebar
- Change AdminLayout to use h-screen instead of min-h-screen
- Add overflow-hidden to prevent scrollbar on outer container
- Update sidebar to use h-screen for proper height reference
- Add flex-shrink-0 to header and bottom sections
- Add min-h-0 to navigation to allow proper overflow
- Add overflow-y-auto to main content area

The version info and storage usage now properly stick to the bottom of the viewport instead of the bottom of the page content.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 09:35:08 +02:00
paul 0a203d16cf Fix translation issues in event creation and detail pages
- Add missing translation keys for event creation form
- Replace all hardcoded strings with translation keys in CreateEventPage
- Add validation error message translations
- Add event types and color theme translations
- Add errors and validation sections to translation files
- Update German translations with corresponding keys

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 09:25:15 +02:00
paul 8231f2b60d Fix gallery login errors and photo upload issues
- Fix photo upload cross-device link error
  - Changed fs.rename to fs.copyFile + fs.unlink to handle Docker volume mounts
  - This fixes EXDEV errors when uploading photos from /tmp to storage

- Improve gallery login error handling
  - Add console logging for debugging
  - Prevent form refresh with stopPropagation
  - Show specific error messages based on status codes
  - Keep password field populated on error for retry
  - Map 404 to gallery not found message

- Enhanced upload error messages
  - Show specific error message for each failed file
  - Display backend error messages in toast notifications

These fixes resolve:
1. Photo uploads failing silently with cross-device link errors
2. Login errors not displaying and form refreshing
3. Upload errors not showing user-friendly messages

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 09:09:34 +02:00
paul 9c1e79b5a5 Fix gallery issues: user uploads, error messages, and translations
- Fix user upload feature not showing in gallery
  - Add allow_user_uploads and upload_category_id to auth response
  - These fields are required for the gallery to show the upload button

- Improve gallery login error messages
  - Add specific translations for wrong password vs rate limiting
  - Map backend error messages to user-friendly translations
  - Added auth.wrongPassword and auth.tooManyAttempts keys

- Gallery pages already have full translation support
  - GalleryPage and GalleryView use i18n properly
  - All text is translated based on selected language

These fixes resolve:
1. User upload button not showing even when enabled
2. Generic error messages for wrong passwords
3. No feedback for rate limiting

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 09:03:14 +02:00
paul 472445a2e5 Fix backend 500 errors for event creation and system version
- Fix system version endpoint to read package.json using fs instead of require
  - Prevents MODULE_NOT_FOUND error in Docker container
  - Uses path.join to find package.json reliably

- Fix event creation email queue error
  - Change email_type from 'creation' to 'gallery_created' to match template key
  - Update email_data to include all required template variables
  - Added missing created_at and updated_at columns to email_queue table

- Fix file watcher duplicate photo insertion
  - Add check to prevent re-inserting existing photos on backend restart

These fixes resolve:
1. 500 error when accessing /api/admin/system/version
2. 500 error when creating new events
3. Email queue processing errors
4. Photo duplication issue

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 08:53:18 +02:00
paul ec3d5a0f80 Fix critical issues: gallery authentication and photo duplication
- Fix gallery-specific authentication for images
  - Update AuthenticatedImage component to use gallery-specific tokens
  - Add isGallery prop to distinguish between admin and gallery contexts
  - Update PhotoGrid and PhotoLightbox to pass isGallery prop

- Fix photo duplication issue in fileWatcher service
  - Add check to prevent duplicate photo entries when backend restarts
  - File watcher now verifies if photo exists before inserting
  - Cleaned up 176 duplicate photos from database

These fixes resolve:
1. Gallery images not loading due to auth token errors
2. Photo count increasing without new uploads due to duplicates

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 08:28:15 +02:00
paul cf32b01356 fix: Implement gallery-specific authentication tokens
- Fix issue where different galleries shared authentication
- Store gallery tokens with slug-specific keys in localStorage
- Remove global gallery_token cookie approach
- Each gallery now maintains its own authentication state
- Add cleanup for legacy authentication data

This ensures that accessing different galleries requires separate authentication
and prevents cross-gallery authentication leakage.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 08:05:04 +02:00
paul c8cfce3e36 fix: Add localized date formatting to gallery views
- Update GalleryLayout to use localized date formatting
- Update GalleryPage to use localized date formatting
- Dates now properly display in German/English based on selected language

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 07:11:08 +02:00
paul 12ba91952e feat: Add comprehensive system enhancements
- Add version display above storage consumption in admin sidebar
- Fix storage consumption to stick to bottom of window using flexbox
- Add user upload settings to events (allow uploads, category selection)
- Enhance disk space tab to comprehensive system status view
- Add localized date formatting for German/English language support
- Remove quick actions from dashboard for cleaner interface
- Create user photo upload functionality for galleries
- Add database migration for user upload settings
- Update all TypeScript types and interfaces

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-08 22:47:04 +02:00
paul 69b56ed582 Implement multi-language email templates
- Add language columns to email_templates table (subject_en/de, body_html_en/de, body_text_en/de)
- Update adminEmail.js routes to support language-specific templates
- Create EmailProcessor service to handle language selection based on recipient
- Update EmailConfigPage component with language tabs similar to CMS pages
- Add German translations for all email templates
- Update all email queue usage to use proper template keys
- Add missing email templates (gallery_expired, archive_complete)
- Integrate email processor service into main server startup

The system now automatically selects the appropriate language (English/German) based on the recipient's email domain or preferences.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-08 17:57:26 +02:00
paul 1bc9b547c7 Complete translation implementation for admin interface
- Fixed all hardcoded strings in admin components to use t() function
- Updated BrandingPage.tsx to use translations for watermark settings
- Updated EventsListPage.tsx to use translations for status labels
- Added missing translation keys to both en.json and de.json
- Fixed translations for:
  - System settings (general, storage, categories tabs)
  - Branding page (watermark settings, positions, opacity)
  - Email configuration and templates
  - Event list view (status labels, filters, actions)
  - Event detail view (all sections properly translated)
- Added comprehensive German translations for all new keys
- Ensured consistent translation usage across all admin pages

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-08 17:41:02 +02:00
paul d594d00227 Fix brand theme application and add comprehensive translations
- Fixed theme not being reflected on gallery and admin login pages
- Created GlobalThemeProvider to apply themes globally
- Updated gallery and admin login pages to use dynamic CSS variables
- Added complete translations for all admin sections in English and German:
  - Notifications management
  - Event view and creation
  - Photo upload functionality
  - Category management
  - Archive page view
  - Analytics dashboard
  - Branding and theme settings
  - System settings
  - CMS page management
  - Email configuration
- Fixed admin photo management display issues
- Fixed photo upload category assignment
- Added password reset functionality for galleries
- Improved error handling and user feedback

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-08 17:07:40 +02:00
paul 2012b0bab9 Fix language setting not being saved to database on admin settings page
- Added default_language field to general settings state in SettingsPage
- Replaced LanguageSelector component with simple select dropdown on settings page
- Fixed public settings endpoint to read general_default_language from database
- Language setting now properly saved when clicking Save Settings button
- Setting is correctly used by gallery login page and legal pages

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-08 09:49:45 +02:00
paul cfa0b0da69 Fix photo upload functionality
- Use proper API instance with authentication headers
- Fix API URL to use backend port (3001) instead of frontend
- Add upload progress tracking
- Add success/error toast notifications
- Show progress bar with percentage during upload
2025-07-07 16:35:55 +02:00
paul eac573c4a5 Fix event-specific themes and branding display
- Update COLOR_THEMES to include full theme configurations
- Send theme as JSON string when creating events
- Display company branding in gallery header
- Add debug logging for theme application
- Event themes now properly override global themes
- Company name and tagline now visible in gallery header
2025-07-07 16:32:46 +02:00
paul ff370f6dbd Fix rate limiting for admin users
- Add skip function to rate limiter that bypasses limits for authenticated admins
- Verify JWT token to identify admin users
- Admin users can now make unlimited API requests
- Other users still subject to rate limits (100 requests per 15 minutes)
2025-07-07 16:26:57 +02:00
paul 7e3009cedc Fix theme preset switching issue
- Fix preset theme selection in BrandingPage
- Add debug logging to track theme changes
- Properly update currentTheme state when preset is selected
- Match saved theme to preset on initialization
- Remove unused imports
2025-07-07 16:15:21 +02:00
paul 23ec674e05 Integrate branding and theme settings with database
- Update BrandingPage to save settings to database instead of localStorage
- Add public settings endpoint for galleries to fetch branding/theme
- Update GalleryView to apply branding settings in footer
- Apply theme settings from database to gallery pages
- Support event-specific themes that override global settings
- Ensure watermark and all branding settings are stored in database
2025-07-07 15:59:48 +02:00
paul 971397c338 Fix general settings route and req.user references
- Update frontend settings service to use correct /api/admin/settings/general route
- Fix all req.user to req.admin references in adminSettings.js
- Ensures settings can be saved without authentication errors
2025-07-07 15:32:42 +02:00
paul f0768cd31b Fix email queue column error in event creation
- Remove created_at field from email_queue insert (table uses scheduled_at)
- Let scheduled_at use its default value from database schema

This fixes the 500 error that occurred when creating events due to
trying to insert a non-existent column.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 14:28:01 +02:00
paul 91601c77a4 Fix React error #130 with comprehensive improvements
Frontend improvements:
- Add enhanced error logging in ErrorBoundary for better debugging
- Add validation for EventDetailsPage ID parameter
- Add delay in CreateEventPage navigation to prevent race conditions
- Fallback to events list if navigation data is invalid
- Add displayName to all critical page components

These changes address the React error #130 by:
1. Preventing navigation to undefined routes
2. Validating component parameters before rendering
3. Adding proper error boundaries with detailed logging
4. Ensuring components are properly mounted before navigation

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 14:21:25 +02:00
paul 53704ec92e Fix event creation errors
- Fix req.user undefined error by using req.admin from middleware
- Return proper ISO date strings in event creation response
- Include created_at in response for frontend navigation

This fixes the 500 error when creating events and ensures the frontend
receives the expected data format for navigation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 14:17:14 +02:00
paul fece843505 Fix date format issues in events API
- Convert Unix timestamps to ISO strings before sending to frontend
- Store dates as ISO strings in database during event creation
- Fix created_at, expires_at, and archived_at date conversions

This resolves the "Invalid time value" error that occurred when viewing
the events page due to SQLite returning dates as Unix timestamps.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 14:14:05 +02:00
paul 35681d5346 Fix date parsing errors in EventsListPage
- Add null checks for all date fields before parsing
- Handle cases where created_at, event_date, or expires_at might be null/undefined
- Prevent "Invalid time value" errors when viewing events list
- Sort function now handles null dates gracefully

This fixes the RangeError that occurred when navigating to the events page
after creating an event.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 13:58:33 +02:00
paul 193cadef27 Fix React error #130 and backend event creation
Frontend fixes:
- Disable verbatimModuleSyntax in TypeScript config to fix module imports
- Add displayName to critical React components for better production debugging
- Configure Vite build with manual chunks for better code splitting
- Enable sourcemaps for production debugging

Backend fixes:
- Remove updated_at field from events table insert (column doesn't exist)
- Fix SQL error that was causing 500 errors on event creation

These changes resolve:
- React error #130 that occurred during login and event creation
- 500 Internal Server Error when creating new events
- Better error tracking in production builds

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 13:30:53 +02:00
paul 2e7cba9e8a Fix storage path issues and React error #130
Backend fixes:
- Add STORAGE_PATH environment variable support
- Fix absolute path references in all backend services
- Update Docker configuration with correct storage path

Frontend fixes:
- Remove individual ErrorBoundary wrappers to fix React error #130
- Remove unused ErrorBoundary import
- Simplify route structure to prevent component mounting issues

This resolves:
- 500 errors when creating events due to storage permission issues
- React error #130 that occurred during event creation
- Consistent storage path handling across all services

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 13:20:40 +02:00
paul 2e10374e2c Fix React error #130 - Remove authentication race condition
- Remove setTimeout delays in AdminAuthContext login function
- Make authentication state updates synchronous
- Replace setTimeout navigation with state-based navigation in AdminLoginPage
- Add proper error handling and component lifecycle management in CreateEventPage
- Prevent navigation if component unmounts during async operations

This fixes the issue where users would see React error #130 during login
and couldn't create events or save settings.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 13:12:39 +02:00
paul 9dd643338b Fix React error #130 - Resolve component import issues
- Fixed wildcard exports in common/index.ts with explicit named exports
- Fixed GalleryView import path in GalleryPage.tsx
- Fixed ToastContainer props formatting in App.tsx
- Ensured all components are properly exported and imported

This should finally resolve the invalid element type error.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 12:56:09 +02:00
paul f75ee680a5 Fix React error #130 - Invalid element type
- Fixed malformed JSX structure in App.tsx Routes configuration
- Added missing service exports in services/index.ts
- Added ErrorBoundary wrappers to all admin routes for consistency
- Fixed indentation and nesting issues in route definitions

This resolves the login and event creation errors.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 11:19:59 +02:00
paul 8dad933ff1 Fix React error #130 by correcting Card component usage
- Fixed all Card components to use padding prop instead of className
- Updated padding values: p-4 -> sm, p-6 -> md, p-8 -> lg
- This resolves the React element type invalid error

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 11:00:15 +02:00
paul 225d017718 Fix multiple production issues and add password change functionality
- Fixed frontend API URL configuration to use correct port 3002
- Fixed create event functionality by adding proper endpoint and fixing JSON parsing
- Fixed email settings save functionality by importing logActivity correctly
- Fixed admin settings save functionality by using api client instead of direct fetch
- Implemented password change functionality with modal and backend endpoint
- Added updated_at column to admin_users table
- Fixed all mock data issues - now using real backend data throughout

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 10:25:38 +02:00
paul f38a8ef598 Fix admin interface issues and 404 errors
- Create adminEvents.js router to handle /api/admin/events endpoints
- Mount events router in admin.js to fix 404 errors
- Fix admin layout CSS - changed from static to flex layout
- Update AdminSidebar positioning from static to relative
- Add missing PUT endpoints for general and security settings
- Fix frontend environment variables in docker-compose.local.yml
- Add build args to Dockerfile.dev for environment variables
- Update CORS to accept requests from all dev servers
- Remove unused imports from SettingsPage

This fixes:
- Events page 404 error
- Admin layout misalignment (sidebar and content on different rows)
- Settings page not loading
- CORS issues between frontend and backend

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-07 08:40:26 +02:00
paul 024c8eac2d Complete Settings page and fix TypeScript issues
- Create comprehensive SettingsPage with General, Storage, and Security tabs
- Add formatBytes method to settings service
- Update AdminSidebar to show real storage usage from backend
- Fix TypeScript errors with react-query v5 (isPending instead of isLoading)
- Remove unused imports and fix type imports
- Add Settings route to App.tsx
- Implement real-time storage monitoring in sidebar

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-06 22:54:17 +02:00
paul 932e5e137c Replace all mock data with real backend integration
- Add database tables for email configs, settings, and activity logs
- Create backend endpoints for dashboard stats, analytics, archives, email config, and settings
- Create frontend service layer (admin, archive, email, settings services)
- Update AdminDashboard to use real statistics and activity data
- Update AnalyticsPage to fetch real analytics from backend
- Update ArchivesPage with pagination and real archive operations
- Update EmailConfigPage to manage real SMTP config and templates
- Remove all mock data and replace with API calls throughout admin interface

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-06 22:46:24 +02:00
paul 3470120a0d Fix admin login and CORS issues
- Update CORS configuration to allow frontend on port 3005
- Fix auth service to map email field to username for backend compatibility
- Add loading state handling in AdminLayout
- Add error boundary to dashboard route
- Fix unused parameter warning in login function

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-06 22:23:14 +02:00
paul 28632e8970 Implement complete frontend with admin panel and theme system
- Add admin authentication and dashboard
- Create event management pages (list, create, edit, archive)
- Implement gallery enhancements (search, sorting, bulk download)
- Add email configuration and archive management pages
- Integrate Umami analytics with tracking throughout the app
- Add comprehensive error boundaries and loading states
- Implement accessibility features (WCAG 2.1 AA compliance)
- Create theme system with preset themes and customization
- Add branding settings and company information management
- Fix backend database initialization and health check
- Configure proper API URLs and environment variables

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-06 22:04:45 +02:00
paul 6c82958c79 Add complete frontend implementation and Docker deployment setup
- Implement React frontend with TypeScript and Tailwind CSS
- Add scrappbook.de-inspired UI design with photo galleries
- Implement authentication, photo viewing, and download features
- Add Docker Swarm configuration with Traefik reverse proxy
- Set up Drone CI/CD pipeline for automated deployments
- Add monitoring stack with Prometheus and Grafana
- Create comprehensive deployment documentation
- Add simple local development setup with docker-compose.local.yml

Features:
- Password-protected galleries with expiration warnings
- Responsive photo grid with lightbox viewer
- Bulk download functionality
- Hot reload development environment
- Email testing with Mailhog
- Production-ready deployment scripts

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-06 20:23:13 +02:00
paul 032bbae50d Add complete setup script to create all remaining files 2025-07-03 16:39:49 +02:00
paul d66ff29b3e Add email service with queue processing 2025-07-03 16:37:37 +02:00
paul 0ea3ee837a Add archive service for automatic ZIP creation 2025-07-03 16:37:14 +02:00
paul 7de326c296 Add expiration checker service 2025-07-03 16:36:49 +02:00
paul fa71cad843 Add complete setup guide 2025-07-03 16:36:21 +02:00
paul ae71834aa6 Add setup script for remaining files 2025-07-03 16:35:28 +02:00
paul 128452f580 Add file watcher service 2025-07-03 16:34:19 +02:00
paul f306a2539d Add gallery routes 2025-07-03 16:33:49 +02:00
paul 160f26f104 Add events routes 2025-07-03 16:33:09 +02:00
paul 01c37098d4 Add authentication routes 2025-07-03 16:32:28 +02:00
paul f78142cda4 Add photo authentication middleware 2025-07-03 16:31:56 +02:00
paul 6c84f701ca Add auth middleware 2025-07-03 16:31:29 +02:00
paul 1802ddaebd Add database configuration 2025-07-03 16:31:05 +02:00
paul d1d48fb3da Add PM2 ecosystem configuration 2025-07-03 16:30:35 +02:00
paul 8ba72e8aa1 Add Jest setup file 2025-07-03 16:30:20 +02:00
paul feea04b1ce Add Jest configuration 2025-07-03 16:30:03 +02:00
paul c2e1d30153 Add backend ESLint configuration 2025-07-03 16:29:50 +02:00
paul 993a132d20 Add backend .dockerignore 2025-07-03 16:29:36 +02:00
paul 1c374a2f82 Add backend .env.example 2025-07-03 16:29:22 +02:00
paul 576e7c5d35 Add backend Dockerfile 2025-07-03 16:29:04 +02:00
paul 07b93636eb Add backend server.js 2025-07-03 16:28:48 +02:00
paul a2fca63d3a Add backend package.json 2025-07-03 16:28:24 +02:00
paul ebf1dabbaa Add production docker-compose configuration 2025-07-03 16:28:05 +02:00
paul 3647855163 Add development docker-compose configuration 2025-07-03 16:27:40 +02:00
paul bdc7e73523 Add environment variables example file 2025-07-03 16:27:23 +02:00
paul f3b83829ca Add .dockerignore file 2025-07-03 16:27:04 +02:00
paul 59b1b87cba Add .gitignore file 2025-07-03 16:26:50 +02:00
paul 206539f51e Initial commit 2025-07-03 16:25:53 +02:00
666 changed files with 14691 additions and 121710 deletions
+286
View File
@@ -0,0 +1,286 @@
# Security Scan Report - Wedding Photo Sharing Application
**Date**: July 13, 2025
**Scanner**: Claude Security Audit with --security --validate flags
**Overall Risk Level**: MEDIUM-HIGH
## Executive Summary
The wedding photo sharing application demonstrates strong security fundamentals with comprehensive input validation, proper authentication mechanisms, and good file security practices. However, several critical issues require immediate attention, particularly around hardcoded secrets, token storage, and Content Security Policy configuration.
### Security Score: 6.5/10
**Strengths**: Excellent input validation, parameterized queries, file security, rate limiting
**Critical Issues**: Hardcoded JWT secrets, localStorage token storage, weak CSP, console logging in production
---
## 🔴 CRITICAL FINDINGS (Immediate Action Required)
### 1. Hardcoded JWT Secret in Development
- **Location**: Backend `.env` file
- **Risk**: Token forgery, authentication bypass
- **Impact**: Complete authentication compromise
- **Remediation**:
```bash
# Generate secure secret
openssl rand -base64 32
# Never commit to repository
echo ".env" >> .gitignore
```
### 2. Gallery Tokens in localStorage
- **Location**: Frontend `api.ts` and auth contexts
- **Risk**: XSS token theft
- **Impact**: Gallery access compromise
- **Remediation**: Move to httpOnly cookies:
```typescript
Cookies.set(`gallery_token_${slug}`, token, {
httpOnly: true,
secure: true,
sameSite: 'strict'
});
```
### 3. Weak Content Security Policy
- **Location**: Frontend `nginx.conf`
- **Risk**: XSS, code injection
- **Current**: `unsafe-inline` and `unsafe-eval` allowed
- **Remediation**: Implement strict CSP (see detailed recommendations below)
---
## 🟠 HIGH SEVERITY FINDINGS
### 1. Console Logging in Production
- **Locations**: 61 instances across frontend
- **Risk**: Information disclosure
- **Impact**: Leaking sensitive data, debugging info
- **Remediation**: Implement environment-aware logging
### 2. Token Revocation Vulnerability
- **Location**: Backend `tokenRevocation.js`
- **Risk**: Token manipulation
- **Impact**: Bypass revocation checks
- **Remediation**: Verify token signature before decoding
### 3. Source Maps in Production
- **Location**: Frontend build configuration
- **Risk**: Source code exposure
- **Impact**: Reveals application structure
- **Remediation**: Disable in production builds
### 4. Missing Security Headers
- **Location**: nginx configuration
- **Missing**: HSTS, Permissions-Policy
- **Impact**: Various client-side attacks
- **Remediation**: Add comprehensive security headers
---
## 🟡 MEDIUM SEVERITY FINDINGS
### 1. Rate Limiting Bypass Potential
- **Location**: Backend rate limiter
- **Risk**: DoS attacks
- **Current**: JWT validation in rate limiter
- **Remediation**: Use IP-based limiting only
### 2. Incomplete SQL Injection Protection
- **Location**: Complex dashboard queries
- **Risk**: Potential injection in edge cases
- **Current**: Mostly parameterized
- **Remediation**: Use query builder exclusively
### 3. Session Management
- **Issue**: No gallery token invalidation on password change
- **Risk**: Persistent access after compromise
- **Remediation**: Implement token revocation
### 4. Path Traversal in Gallery Slugs
- **Location**: Frontend gallery routes
- **Risk**: Directory traversal attempts
- **Remediation**: Validate and sanitize slugs
---
## 🟢 LOW SEVERITY FINDINGS
### 1. Verbose Error Messages
- **Location**: Multiple API endpoints
- **Risk**: Information disclosure
- **Remediation**: Generic client errors, detailed server logs
### 2. Weak Gallery Passwords
- **Current**: zxcvbn score 2/4 allowed
- **Risk**: Brute force attacks
- **Remediation**: Increase to score 3/4
### 3. Missing File Size Validation
- **Location**: Frontend upload components
- **Risk**: DoS via large uploads
- **Remediation**: Add client-side size checks
---
## ✅ SECURITY STRENGTHS
### Authentication & Authorization
- JWT with proper expiration (24h/7d)
- Token type validation
- IP tracking and validation
- Password change detection
- Token revocation system
- Bcrypt with 12 rounds
- zxcvbn password strength checking
### Input Validation & SQL Security
- express-validator on all endpoints
- Parameterized queries via Knex
- SQL injection protection utilities
- Path traversal prevention
- Comprehensive input sanitization
### File Security
- Magic number verification
- MIME type validation
- Safe filename generation
- Directory traversal protection
- File extension whitelist
### Rate Limiting & DoS Protection
- General: 100 req/15min
- Auth endpoints: 5 req/15min
- Account lockout after failed attempts
- Suspicious activity detection
### Frontend Security
- React's built-in XSS protection
- DOMPurify for HTML content
- No eval() or innerHTML usage
- Proper error boundaries
- ReCAPTCHA integration
---
## 📊 DEPENDENCY ANALYSIS
### Current Status
- **Backend**: 0 vulnerabilities (691 packages)
- **Frontend**: 0 vulnerabilities (434 packages)
### Recommended Updates
1. **bcrypt** 5.1.1 → 6.0.0 (performance, compatibility)
2. **helmet** 7.2.0 → 8.1.0 (new security features)
3. **@tiptap** 2.x → 3.x (security improvements)
### Supply Chain Assessment
- All major dependencies from trusted sources
- No typosquatting detected
- Regular maintenance observed
- MIT/ISC/Apache licenses only
---
## 🛠️ REMEDIATION PLAN
### Phase 1: Critical (Within 24 hours)
1. Replace hardcoded JWT secret with secure random value
2. Move gallery tokens from localStorage to httpOnly cookies
3. Implement strict CSP without unsafe-eval
4. Remove or wrap console.log statements
### Phase 2: High Priority (Within 1 week)
1. Disable source maps in production
2. Add missing security headers (HSTS, Permissions-Policy)
3. Fix token revocation vulnerability
4. Update critical dependencies (bcrypt, helmet)
### Phase 3: Medium Priority (Within 1 month)
1. Implement comprehensive logging strategy
2. Add gallery slug validation
3. Enhance rate limiting logic
4. Implement session invalidation on password change
### Phase 4: Ongoing
1. Weekly dependency scanning
2. Implement security testing in CI/CD
3. Regular penetration testing
4. Security awareness training
---
## 🔒 RECOMMENDED CSP CONFIGURATION
```nginx
add_header Content-Security-Policy "
default-src 'self';
script-src 'self' 'nonce-{RANDOM}' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob: https:;
font-src 'self';
connect-src 'self' https://analytics.domain.com;
frame-src https://www.google.com/recaptcha/;
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
upgrade-insecure-requests;
" always;
```
---
## 🚀 SECURITY IMPROVEMENTS ROADMAP
### Immediate Implementation
```bash
# 1. Generate secure secrets
openssl rand -base64 32 > jwt-secret.txt
# 2. Update dependencies
cd backend && npm install bcrypt@^6.0.0 helmet@^8.1.0
cd ../frontend && npm update
# 3. Add security scanning
npm install -D npm-audit-resolver
```
### CI/CD Integration
```yaml
# Add to CI pipeline
- name: Security Scan
run: |
npm audit --audit-level=moderate
npm run test:security
```
### Monitoring & Alerting
1. Implement fail2ban for repeated auth failures
2. Set up log analysis for suspicious patterns
3. Configure alerts for security events
4. Regular vulnerability scanning
---
## 📋 COMPLIANCE CHECKLIST
- [ ] OWASP Top 10 addressed
- [ ] GDPR compliance (data minimization, right to erasure)
- [ ] Security headers implemented
- [ ] Dependency scanning automated
- [ ] Incident response plan documented
- [ ] Security documentation maintained
- [ ] Regular security reviews scheduled
---
## 🎯 CONCLUSION
The wedding photo sharing application has a solid security foundation with excellent input validation and authentication mechanisms. However, operational security practices need immediate attention. The critical issues around secret management and token storage must be addressed before production deployment.
Implementing the recommended fixes will raise the security score from 6.5/10 to approximately 8.5/10, providing a robust and secure platform for wedding photo sharing.
---
*Generated by Claude Security Scanner v1.0*
*Next scan recommended: After Phase 1 remediation completion*
+77
View File
@@ -0,0 +1,77 @@
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
trigger:
event:
- tag
+27 -193
View File
@@ -1,200 +1,34 @@
# PicPeak Environment Configuration
# Copy this file to .env and update with your values
# Environment Configuration Template
# Copy this file to .env and adjust values for your environment
# Environment
NODE_ENV=production
# Development: Use docker-compose.dev.yml
# Production: Use docker-compose.prod.yml with .env.production.example
# JWT Secret (generate with: openssl rand -base64 64)
JWT_SECRET=your_very_long_random_jwt_secret_here
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
# auto - decide per request: Secure on HTTPS, not on HTTP
#
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
# (via reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
# HTTP (e.g. LAN access at http://192.168.x.x:3010). The backend reads
# req.secure from Express, which respects the X-Forwarded-Proto header
# when the proxy is in the trust list.
#
# Requirements for auto mode:
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
# 192.168.x, link-local). Proxies outside those ranges need custom
# trust proxy configuration.
# COOKIE_SECURE=auto
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
# COOKIE_SAMESITE=Lax
# Cookie Domain — set this if serving auth cookies across subdomains.
# Leave unset for same-origin setups.
# COOKIE_DOMAIN=.example.com
# Database Configuration (PostgreSQL)
DATABASE_CLIENT=pg
DB_USER=picpeak
# IMPORTANT: Avoid $ character in passwords - Docker Compose interprets it as variable substitution
# If you must use $, escape it as $$ (e.g., Pass$$word instead of Pass$word)
DB_PASSWORD=your_secure_postgres_password_here
DB_NAME=picpeak_prod
# Redis Configuration
# IMPORTANT: Same warning applies - avoid $ or escape as $$
REDIS_PASSWORD=your_secure_redis_password_here
# Admin Account (initial setup)
ADMIN_USERNAME=admin
ADMIN_EMAIL=admin@yourdomain.com
ADMIN_PASSWORD=your_secure_admin_password_here
# Email Configuration
# For Gmail: use app-specific password
# For SendGrid: SMTP_USER=apikey, SMTP_PASS=your-api-key
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-specific-password
EMAIL_FROM=noreply@yourdomain.com
# JWT Secret (CRITICAL for production)
# Generate with: openssl rand -base64 32
JWT_SECRET=dev-secret-change-in-production
# 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
ADMIN_URL=http://localhost:3005
FRONTEND_URL=http://localhost:3005
# API URL for email assets (logos, images in notification emails)
# This must be the publicly accessible URL where email recipients can load images.
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
API_URL=https://yourdomain.com/api
# Database Configuration
# SQLite is used for development by default
# For production PostgreSQL config, see .env.production.example
DATABASE_CLIENT=sqlite3
DATABASE_PATH=./data/photo_sharing.db
# 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
# Email Configuration
# Development: Uses Mailhog (included in docker-compose.dev.yml)
# Production: Configure real SMTP server
SMTP_HOST=mailhog
SMTP_PORT=1025
SMTP_SECURE=false
SMTP_USER=
SMTP_PASS=
EMAIL_FROM=noreply@localhost
# Port Configuration (optional)
# BACKEND_PORT=3001
# FRONTEND_PORT=3000
# DB_PORT=5432
# REDIS_PORT=6379
# Release Channel
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
# 'stable' uses the :stable tag (same as :latest on main)
# 'beta' uses the :beta tag for pre-release versions
PICPEAK_CHANNEL=stable
# Update Check Configuration
# Set to 'false' to disable update notifications in admin UI
UPDATE_CHECK_ENABLED=true
# Timezone
TZ=UTC
# 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=
# 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
# ─── Storage Backend ────────────────────────────────────────────────────────
# PicPeak can store photos, thumbnails and archive zips on the local filesystem
# (default) or on any S3-compatible object store (AWS S3, MinIO, Cloudflare R2,
# Backblaze B2, Wasabi, DigitalOcean Spaces, …).
#
# STORAGE_BACKEND=local (default)
# Uses STORAGE_PATH on the local filesystem. Backwards compatible — every
# existing deployment keeps working unchanged.
#
# STORAGE_BACKEND=s3
# Reads STORAGE_S3_* below. Auto-import via the filesystem watcher is
# disabled in this mode (S3 has no inotify) — every photo must enter via the
# admin upload UI/API. Run `node backend/scripts/migrate-storage.js` to copy
# existing local content to S3 before flipping the env.
#
# STORAGE_BACKEND=local
#
# STORAGE_S3_BUCKET=picpeak
# STORAGE_S3_REGION=us-east-1
# STORAGE_S3_ACCESS_KEY=AKIAxxxxxxxxxxxxxxxx
# STORAGE_S3_SECRET_KEY=xxxxxxxxxxxxxxxxxxxxxxxx
# Custom endpoint — set this for MinIO / R2 / B2 / Spaces. Leave unset for AWS.
# STORAGE_S3_ENDPOINT=https://s3.us-west-002.backblazeb2.com
# Optional namespace prefix inside the bucket — useful for multi-deployment buckets.
# STORAGE_S3_PREFIX=picpeak
# STORAGE_S3_FORCE_PATH_STYLE=false # MinIO needs true; auto-on when endpoint is set
# STORAGE_S3_SSL=true
#
# Minimum IAM policy (AWS S3) for the bucket above:
# {
# "Version": "2012-10-17",
# "Statement": [{
# "Effect": "Allow",
# "Action": [
# "s3:GetObject", "s3:PutObject", "s3:DeleteObject",
# "s3:ListBucket", "s3:GetBucketLocation"
# ],
# "Resource": [
# "arn:aws:s3:::picpeak",
# "arn:aws:s3:::picpeak/*"
# ]
# }]
# }
#
# EXTERNAL_MEDIA_ROOT (above) always lives on the local filesystem regardless
# of STORAGE_BACKEND — reference-mode galleries are not migrated to S3 in v1.
# ─── Outbound Webhooks (#327) ────────────────────────────────────────────────
# PicPeak POSTs event/photo lifecycle notifications to URLs you configure
# under Settings → Webhooks. Each delivery is signed HMAC-SHA256 with a
# per-webhook secret in the X-PicPeak-Signature header.
#
# WEBHOOK_ALLOW_PRIVATE_URLS (default: false)
# Block URLs resolving to private IPs / loopback / .local etc. as an
# SSRF mitigation. Set to "true" ONLY in dev when your receiver is on
# the same docker network or localhost. Production deployments must
# leave this OFF.
# WEBHOOK_ALLOW_PRIVATE_URLS=false
#
# WEBHOOK_DELIVERY_INTERVAL_MS (default: 5000)
# How often the worker polls webhook_deliveries for pending rows.
# WEBHOOK_DELIVERY_INTERVAL_MS=5000
#
# WEBHOOK_DELIVERY_CONCURRENCY (default: 5)
# Maximum in-flight deliveries per worker tick. One slow consumer can
# monopolize all 5 slots — bump this if your receivers are slow OR ship
# a separate webhook-only deployment.
# WEBHOOK_DELIVERY_CONCURRENCY=5
#
# WEBHOOK_HTTP_TIMEOUT_MS (default: 10000)
# Per-request timeout. Beyond this, the delivery is recorded as a
# network error and retried.
# WEBHOOK_HTTP_TIMEOUT_MS=10000
#
# WEBHOOK_MAX_ATTEMPTS (default: 5)
# Total attempts before a delivery is marked failed. Backoff between
# attempts is exponential: 1m, 5m, 30m, 2h, 12h.
# WEBHOOK_MAX_ATTEMPTS=5
# 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.
# Optional: Umami Analytics
UMAMI_URL=
UMAMI_WEBSITE_ID=
UMAMI_HASH_SALT=
+44
View File
@@ -0,0 +1,44 @@
# PicPeak Production Configuration
# Copy this file to .env and update with your values
# Required: Security
JWT_SECRET=CHANGE_THIS_TO_RANDOM_32_CHAR_STRING
# Required: URLs (update with your domain)
FRONTEND_URL=https://your-domain.com
BACKEND_URL=https://your-domain.com
ADMIN_URL=https://your-domain.com
# Required: Email Settings
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=your-email@gmail.com
# Required: Initial Admin Account
ADMIN_EMAIL=admin@your-domain.com
ADMIN_PASSWORD=change-this-password
# Database (PostgreSQL recommended for production)
DATABASE_CLIENT=pg
DB_HOST=postgres
DB_PORT=5432
DB_NAME=picpeak
DB_USER=picpeak
DB_PASSWORD=secure-database-password
# Optional: Customization
SITE_NAME=PicPeak
DEFAULT_EXPIRATION_DAYS=30
SESSION_TIMEOUT_MINUTES=60
# Optional: Analytics (Umami)
VITE_UMAMI_URL=
VITE_UMAMI_WEBSITE_ID=
# Advanced: Performance Tuning
NODE_ENV=production
BCRYPT_ROUNDS=12
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
+12
View File
@@ -0,0 +1,12 @@
# Files to exclude from GitHub mirror
.env* export-ignore
docker-compose.prod.yml export-ignore
.claudedocs/ export-ignore
backend/data/ export-ignore
backend/storage/ export-ignore
backend/.env* export-ignore
frontend/.env* export-ignore
secrets/ export-ignore
*.key export-ignore
*.pem export-ignore
.gitea/ export-ignore
+59
View File
@@ -0,0 +1,59 @@
name: Mirror to GitHub
on:
push:
branches:
- main
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0 # Full history needed for mirroring
- name: Setup Git
run: |
git config --global user.name "the-luap"
git config --global user.email "paul-nothaft@hotmail.de"
- name: Create filtered branch
run: |
# Create a new branch for GitHub
git checkout --orphan -b github-mirror
# Remove sensitive files/directories
# Example: Remove .env files, private configs, etc.
git rm -r --cached .env* || true
git rm -r --cached backend/.env* || true
git rm -r --cached frontend/.env* || true
git rm -r --cached docker-compose.prod.yml || true
git rm -r --cached .claudedocs/ || true
git rm -r --cached backend/data/ || true
git rm -r --cached backend/storage/ || true
git rm -r --cached .gitea/ || true
git rm -r --cached scripts/install-gitea-runner.sh || true
git rm -r --cached .drone* || true
git rm -r --cached .github-mirror-exclude || true
git rm -r --cached .gitattributes-github || true
git rm -r --cached photo-sharing-prd.md || true
git rm -r --cached CLAUDE.md || true
git rm -r --cached PRODUCTION_DEPLOYMENT_GUIDE.md || true
git rm -r --cached logs/ || true
# Commit the changes
git commit -m "Remove sensitive files for GitHub mirror" || true
- name: Push to GitHub
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
run: |
# Add GitHub remote
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
# Force push the filtered branch to GitHub main
git push github github-mirror:main --force
+52
View File
@@ -0,0 +1,52 @@
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
+88
View File
@@ -0,0 +1,88 @@
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 }}
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: Bump version
id: version
run: |
# Get current version from backend package.json
CURRENT_VERSION=$(node -p "require('./backend/package.json').version")
echo "Current version: $CURRENT_VERSION"
# Split version into parts
IFS='.' read -r -a version_parts <<< "$CURRENT_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
# Update version in package.json files
cd backend && npm version $NEW_VERSION --no-git-tag-version
cd ../frontend && npm version $NEW_VERSION --no-git-tag-version
cd ..
# Check if there are changes
if [[ -n $(git status -s) ]]; 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: |
git add backend/package.json backend/package-lock.json
git add frontend/package.json frontend/package-lock.json
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }}"
git push
- name: Create Git tag
if: steps.version.outputs.version_changed == 'true'
run: |
git tag -a "v${{ steps.version.outputs.new_version }}" -m "Release v${{ steps.version.outputs.new_version }}"
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 "Drone will automatically trigger on the new tag"
# Drone CI will automatically trigger on the tag push event
+24
View File
@@ -0,0 +1,24 @@
# Exclude patterns for GitHub mirror
.env
.env.*
.env*
docker-compose.prod.yml
docker-compose.traefik.yml
.claudedocs/
backend/data/
backend/storage/
backend/.env*
frontend/.env*
secrets/
*.key
*.pem
.gitea/
node_modules/
dist/
build/
*.log
.DS_Store
deploy/
certbot/
nginx/
photo-sharing-prd.md
@@ -9,7 +9,7 @@ assignees: ''
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
Instead, please use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new) or email **info@picpeak.app** with the details.
Instead, please email security@example.com with the details.
For minor security improvements or questions, you can use this template:
-213
View File
@@ -1,213 +0,0 @@
# Docker Build and Push Workflow
This GitHub Actions workflow automatically builds and pushes Docker images for both the backend and frontend to GitHub Container Registry (ghcr.io).
## Features
- 🔧 **Automatic builds** on push to main/develop branches, PRs, and releases
- 🏗️ **Multi-architecture support** (linux/amd64 and linux/arm64)
- 🏷️ **Smart tagging** based on branches, versions, and commits
- 🔒 **Security scanning** with Trivy vulnerability scanner
- 💾 **Build caching** for faster subsequent builds
- 📊 **Build summaries** in GitHub Actions UI
## Authentication
The workflow uses the built-in `GITHUB_TOKEN` for authentication with GitHub Container Registry. No additional setup or personal access tokens are required.
### Required Permissions
The workflow automatically sets the necessary permissions:
- `contents: read` - To checkout the repository
- `packages: write` - To push images to ghcr.io
- `security-events: write` - To upload security scan results
## Image Tags
Images are automatically tagged based on the trigger event:
| Event | Tags Generated |
|-------|---------------|
| Push to main | `latest`, `main`, `main-<short-sha>` |
| Push to develop | `develop`, `develop-<short-sha>` |
| Pull Request | `pr-<number>` |
| Release (v1.2.3) | `1.2.3`, `1.2`, `1`, `latest` |
| Manual trigger | Based on branch + optional push |
## Usage
### Pull Images
Once published, images can be pulled using:
```bash
# Pull backend image
docker pull ghcr.io/the-luap/picpeak/backend:latest
# Pull frontend image
docker pull ghcr.io/the-luap/picpeak/frontend:latest
# Pull specific version
docker pull ghcr.io/the-luap/picpeak/backend:v1.0.0
# Pull for specific architecture
docker pull --platform linux/arm64 ghcr.io/the-luap/picpeak/backend:latest
```
### Using in Docker Compose
```yaml
version: '3.8'
services:
backend:
image: ghcr.io/the-luap/picpeak/backend:latest
environment:
- NODE_ENV=production
ports:
- "3001:3000"
frontend:
image: ghcr.io/the-luap/picpeak/frontend:latest
ports:
- "80:80"
```
### Using in Kubernetes
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: picpeak-backend
spec:
replicas: 3
template:
spec:
containers:
- name: backend
image: ghcr.io/the-luap/picpeak/backend:latest
imagePullPolicy: Always
```
## Manual Workflow Trigger
You can manually trigger the workflow from the Actions tab:
1. Go to Actions → "Build and Push Docker Images"
2. Click "Run workflow"
3. Select branch and whether to push images
4. Click "Run workflow"
## Security Scanning
The workflow includes Trivy vulnerability scanning that:
- Scans for CRITICAL and HIGH severity vulnerabilities
- Uploads results to GitHub Security tab
- Available under Security → Code scanning alerts
## Build Optimization
The workflow uses several optimization techniques:
1. **GitHub Actions Cache**: Speeds up builds by caching layers
2. **Multi-stage builds**: Reduces final image size
3. **Parallel builds**: Backend and frontend build simultaneously
4. **Smart rebuilds**: Only rebuilds changed components
## Troubleshooting
### Permission Denied Errors
If you encounter permission errors when pushing images:
1. **First-time setup**: The first push creates a private package. You may need to:
- Go to your package settings at `https://github.com/users/YOUR_USERNAME/packages`
- Link the package to your repository
- Set package visibility (public/private)
2. **Organization repositories**: Ensure the organization allows GitHub Actions to create packages
### Build Failures
Check the workflow logs in the Actions tab for detailed error messages. Common issues:
- Missing dependencies in package.json
- Dockerfile syntax errors
- Network issues during package installation
### Image Not Found
If images aren't visible after successful push:
- Check package visibility settings
- Ensure you're authenticated to pull private images:
```bash
echo $GITHUB_TOKEN | docker login ghcr.io -u YOUR_USERNAME --password-stdin
```
## Package Management
### View Packages
Your Docker images are available at:
- Backend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Fbackend`
- Frontend: `https://github.com/users/the-luap/packages/container/package/picpeak%2Ffrontend`
### Delete Old Versions
To save storage, you can delete old versions:
1. Go to package settings
2. Click on "Manage versions"
3. Select versions to delete
4. Click "Delete selected versions"
### Set Retention Policy
Configure automatic cleanup in package settings:
1. Go to package settings
2. Click on "Manage Actions access"
3. Set retention days for untagged versions
## Best Practices
1. **Use semantic versioning** for releases (e.g., v1.2.3)
2. **Test images locally** before pushing to production
3. **Monitor security alerts** from Trivy scans
4. **Clean up old images** regularly to save storage
5. **Use specific tags** in production (avoid `latest`)
## Advanced Configuration
### Custom Registry
To use a different registry, update the workflow:
```yaml
env:
REGISTRY: docker.io # or your custom registry
BACKEND_IMAGE_NAME: yourusername/picpeak-backend
```
### Additional Platforms
To build for more platforms:
```yaml
platforms: linux/amd64,linux/arm64,linux/arm/v7
```
### Custom Build Arguments
Add build arguments in the workflow:
```yaml
build-args: |
NODE_VERSION=20
API_URL=${{ secrets.API_URL }}
```
## Related Documentation
- [GitHub Container Registry Docs](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry)
- [Docker Build Action](https://github.com/docker/build-push-action)
- [Trivy Security Scanner](https://github.com/aquasecurity/trivy)
- [Multi-platform Builds](https://docs.docker.com/build/building/multi-platform/)
-503
View File
@@ -1,503 +0,0 @@
name: Build and Push Docker Images
# This workflow is triggered by:
# - Push to main/beta branches (builds 'latest'/'stable' or 'beta' tagged images)
# - Version tags from Release Please (e.g., v1.2.0 -> builds versioned images)
# - GitHub Releases (created by Release Please)
# - Pull requests (build verification only, no push by default)
# - Manual workflow dispatch
#
# Multi-arch strategy:
# Each image (backend, frontend) is built once per architecture on a
# native runner — linux/amd64 on ubuntu-latest, linux/arm64 on
# ubuntu-24.04-arm. Each leg pushes by digest to GHCR. A follow-up
# merge job combines the digests into a multi-arch manifest and applies
# the human-readable tags. This is the pattern documented at
# https://docs.docker.com/build/ci/github-actions/multi-platform/
#
# Native runners are used instead of QEMU because npm install under
# QEMU was previously too slow/unreliable for regular branch builds.
on:
push:
branches: [ main, beta ]
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
pull_request:
branches: [ main, beta ]
release:
types: [ published ] # Triggered when Release Please creates a release
workflow_dispatch:
inputs:
push:
description: 'Push images to registry'
required: false
default: 'false'
type: choice
options:
- 'true'
- 'false'
env:
REGISTRY: ghcr.io
# BACKEND_IMAGE_NAME and FRONTEND_IMAGE_NAME are computed per job in the
# "Compute image names" step. GHCR requires all-lowercase repository names,
# but ${{ github.repository }} preserves the original case (e.g. "Luca-Timo/...").
# Computing them with bash parameter expansion (${VAR,,}) keeps the workflow
# working on forks regardless of the owner's name casing.
jobs:
# -----------------------------------------------------------------------------
# Backend: per-arch build, then merge into a multi-arch manifest
# -----------------------------------------------------------------------------
build-backend:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
- name: Prepare platform pair
run: |
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- 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 }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine if pushing
id: push-decision
run: |
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
else
echo "push=true" >> "$GITHUB_OUTPUT"
fi
- name: Extract metadata for Backend (labels only)
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Backend
org.opencontainers.image.description=PicPeak photo sharing platform backend service
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
- name: Build Backend image (push by digest)
id: build
uses: docker/build-push-action@v5
with:
context: ./backend
file: ./backend/Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-backend.outputs.labels }}
cache-from: type=gha,scope=backend-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=backend-${{ env.PLATFORM_PAIR }}
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.BACKEND_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-backend.outputs.version }}
- name: Export digest
if: steps.push-decision.outputs.push == 'true'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: steps.push-decision.outputs.push == 'true'
uses: actions/upload-artifact@v4
with:
name: digests-backend-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-backend:
needs: build-backend
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
# Only run when at least one digest was pushed (i.e. not on PRs without push intent).
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
steps:
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
- name: Download digest artifacts
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-backend-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Extract metadata for Backend
id: meta-backend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Backend
org.opencontainers.image.description=PicPeak photo sharing platform backend service
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
- name: Run Trivy vulnerability scanner
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 }}
format: 'sarif'
output: 'trivy-backend.sarif'
severity: 'CRITICAL,HIGH'
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-backend.sarif'
category: 'backend-vulnerabilities'
# -----------------------------------------------------------------------------
# Frontend: per-arch build, then merge into a multi-arch manifest
# -----------------------------------------------------------------------------
build-frontend:
strategy:
fail-fast: false
matrix:
include:
- platform: linux/amd64
runner: ubuntu-latest
- platform: linux/arm64
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
permissions:
contents: read
packages: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
- name: Prepare platform pair
run: |
platform="${{ matrix.platform }}"
echo "PLATFORM_PAIR=${platform//\//-}" >> "$GITHUB_ENV"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- 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 }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine if pushing
id: push-decision
run: |
if [[ "${{ github.event_name }}" == "pull_request" && "${{ github.event.inputs.push }}" != "true" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
elif [[ "${{ steps.login-ghcr.outcome }}" != "success" ]]; then
echo "push=false" >> "$GITHUB_OUTPUT"
else
echo "push=true" >> "$GITHUB_OUTPUT"
fi
- name: Extract metadata for Frontend (labels only)
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Frontend
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
- name: Build Frontend image (push by digest)
id: build
uses: docker/build-push-action@v5
with:
context: ./frontend
file: ./frontend/Dockerfile
platforms: ${{ matrix.platform }}
labels: ${{ steps.meta-frontend.outputs.labels }}
cache-from: type=gha,scope=frontend-${{ env.PLATFORM_PAIR }}
cache-to: type=gha,mode=max,scope=frontend-${{ env.PLATFORM_PAIR }}
outputs: ${{ steps.push-decision.outputs.push == 'true' && format('type=image,name={0}/{1},push-by-digest=true,name-canonical=true,push=true', env.REGISTRY, env.FRONTEND_IMAGE_NAME) || 'type=cacheonly' }}
build-args: |
CACHEBUST=${{ github.run_number }}
BUILD_DATE=${{ github.event.head_commit.timestamp }}
VCS_REF=${{ github.sha }}
VERSION=${{ steps.meta-frontend.outputs.version }}
- name: Export digest
if: steps.push-decision.outputs.push == 'true'
run: |
mkdir -p /tmp/digests
digest="${{ steps.build.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: steps.push-decision.outputs.push == 'true'
uses: actions/upload-artifact@v4
with:
name: digests-frontend-${{ env.PLATFORM_PAIR }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
merge-frontend:
needs: build-frontend
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
security-events: write
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
steps:
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
- name: Download digest artifacts
uses: actions/download-artifact@v4
with:
path: /tmp/digests
pattern: digests-frontend-*
merge-multiple: true
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Container Registry
id: login-ghcr
continue-on-error: true
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine build context
id: context
run: |
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
echo "channel=beta" >> $GITHUB_OUTPUT
echo "is_prerelease=true" >> $GITHUB_OUTPUT
else
echo "channel=stable" >> $GITHUB_OUTPUT
echo "is_prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Extract metadata for Frontend
id: meta-frontend
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
labels: |
org.opencontainers.image.title=PicPeak Frontend
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
org.opencontainers.image.vendor=PicPeak
maintainer=${{ github.repository_owner }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
- name: Create and push multi-arch manifest
working-directory: /tmp/digests
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf "${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@sha256:%s " *)
- name: Inspect manifest
run: |
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
- name: Run Trivy vulnerability scanner
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 }}
format: 'sarif'
output: 'trivy-frontend.sarif'
severity: 'CRITICAL,HIGH'
timeout: '10m'
- name: Upload Trivy scan results to GitHub Security tab
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
uses: github/codeql-action/upload-sarif@v4
with:
sarif_file: 'trivy-frontend.sarif'
category: 'frontend-vulnerabilities'
summary:
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
if: always()
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Compute image names (lowercase for GHCR)
run: |
repo_lc="${GITHUB_REPOSITORY,,}"
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
- name: Build Summary
run: |
echo "## 🐳 Docker Build Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
if [[ "${{ needs.build-backend.result }}" == "success" ]]; then
echo "✅ **Backend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Backend build (per-arch)**: ${{ needs.build-backend.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.merge-backend.result }}" == "success" ]]; then
echo "✅ **Backend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
elif [[ "${{ needs.merge-backend.result }}" == "skipped" ]]; then
echo "️ **Backend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Backend manifest merge**: ${{ needs.merge-backend.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.build-frontend.result }}" == "success" ]]; then
echo "✅ **Frontend build (per-arch)**: Successfully built" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Frontend build (per-arch)**: ${{ needs.build-frontend.result }}" >> $GITHUB_STEP_SUMMARY
fi
if [[ "${{ needs.merge-frontend.result }}" == "success" ]]; then
echo "✅ **Frontend manifest merge**: Successfully published" >> $GITHUB_STEP_SUMMARY
elif [[ "${{ needs.merge-frontend.result }}" == "skipped" ]]; then
echo "️ **Frontend manifest merge**: Skipped (verify-only build)" >> $GITHUB_STEP_SUMMARY
else
echo "❌ **Frontend manifest merge**: ${{ needs.merge-frontend.result }}" >> $GITHUB_STEP_SUMMARY
fi
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY
echo "Published manifests include both \`linux/amd64\` and \`linux/arm64\` (built natively, no QEMU)." >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### 🏷️ Tags" >> $GITHUB_STEP_SUMMARY
echo "Images are tagged based on:" >> $GITHUB_STEP_SUMMARY
echo "- Branch name (for branch pushes)" >> $GITHUB_STEP_SUMMARY
echo "- PR number (for pull requests, when push is enabled)" >> $GITHUB_STEP_SUMMARY
echo "- Version tags (for releases)" >> $GITHUB_STEP_SUMMARY
echo "- Short SHA" >> $GITHUB_STEP_SUMMARY
echo "- \`latest\` (for main branch)" >> $GITHUB_STEP_SUMMARY
echo "- \`stable\` (for main branch and stable releases)" >> $GITHUB_STEP_SUMMARY
echo "- \`beta\` (for beta branch and pre-releases)" >> $GITHUB_STEP_SUMMARY
-37
View File
@@ -1,37 +0,0 @@
name: Release Please (Beta)
on:
push:
branches: [beta]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.version }}
steps:
- name: Run Release Please
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config-beta.json
manifest-file: .release-please-manifest-beta.json
target-branch: beta
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
run: |
echo "## Beta Release Created!" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ steps.release.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
-36
View File
@@ -1,36 +0,0 @@
name: Release Please
on:
push:
branches: [main]
permissions:
contents: write
pull-requests: write
jobs:
release-please:
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs.release_created }}
tag_name: ${{ steps.release.outputs.tag_name }}
version: ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}
steps:
- name: Run Release Please
uses: googleapis/release-please-action@v4
id: release
with:
token: ${{ secrets.GITHUB_TOKEN }}
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
- name: Output Release Info
if: ${{ steps.release.outputs.release_created }}
run: |
echo "## Release Created! " >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
echo "**Version:** ${{ steps.release.outputs.major }}.${{ steps.release.outputs.minor }}.${{ steps.release.outputs.patch }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
-75
View File
@@ -11,9 +11,6 @@ yarn-error.log*
.env.test.local
.env.production.local
# Docker override file
docker-compose.override.yml
# Security - Never commit credentials
ADMIN_CREDENTIALS.txt
ADMIN_PASSWORD_RESET.txt
@@ -51,81 +48,9 @@ coverage/
*.tmp
*.temp
# Backup and test directories
backups/
test-archiver/
# Keep directory structure
!storage/events/active/.gitkeep
!storage/events/archived/.gitkeep
!storage/thumbnails/.gitkeep
!data/.gitkeep
!logs/.gitkeep
# development files
backend/.swarm/
.claudedocs/
backend/data/
backend/docs/
backend/logs/
logs/
# Anchored to repo root: matches the top-level runtime storage dir,
# NOT backend/src/services/storage/ (the storage backend abstraction code).
/storage/
data/
certbot/
# Ignore local contributor guide copy
AGENTS.md
CLAUDE.md
# Working/planning documents (not for release)
BUGS_AND_FEATURES.md
frontend/TEST_PLAN.md
docs/REFACTORING_PLAN.md
docs/MULTIPLE_ADMINISTRATORS_PLAN.md
docs/*_PLAN.md
docs/test-*.md
docs/feature-*.md
# Scaffolding documentation (local development reference)
docs/DATABASE_SCHEMA.md
docs/BACKEND_SERVICES.md
docs/API_ROUTES.md
docs/FRONTEND_ARCHITECTURE.md
docs/DEVELOPER_ONBOARDING.md
docs/ENVIRONMENT_VARIABLES.md
# Build artifact: OpenAPI spec generated locally + synced into the
# picpeak-docs repo. Never tracked here — the docs site at
# docs.picpeak.app is the source of truth.
docs/openapi.json
docs/openapi.yaml
# Local backup directory (from testing)
backup/
# Local artifacts from browser tooling
.playwright-mcp/
# Local-only E2E suite (never pushed; runs as pre-push gate on this machine)
tests/e2e/local/
playwright-local-results/
e2e-test.log
scripts/e2e-local.sh
# Local SQLite files in backend
backend/*.sqlite*
backend/*.db
# Test files and artifacts
test-images/
test-logo*.jpg
test-logo*.png
test-results/
# Development docker compose
docker-compose.dev.yml
# New layout development files
new-layouts/
-3
View File
@@ -1,3 +0,0 @@
{
".": "3.42.1-beta.0"
}
-1
View File
@@ -1 +0,0 @@
{".":"3.43.1"}
-1997
View File
File diff suppressed because it is too large Load Diff
+261
View File
@@ -0,0 +1,261 @@
# 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
```bash
docker-compose -f docker-compose.prod.yml up -d # Production deployment
pm2 start ecosystem.config.js # Alternative: PM2 deployment
```
## 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
### 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
## 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;
```
## Success Metrics (from PRD)
- Time to generate gallery: <2 minutes
- Guest satisfaction: >90%
- System uptime: 99.9%
- Email delivery rate: >98%
- Successful archiving: 100%
+1 -1
View File
@@ -20,7 +20,7 @@ We are committed to providing a welcoming and inspiring community for all photog
## Enforcement
Instances of unacceptable behavior may be reported by [opening an issue](https://github.com/the-luap/picpeak/issues/new?labels=conduct) on GitHub. All complaints will be reviewed and investigated promptly and fairly.
Instances of unacceptable behavior may be reported to the project team at conduct@example.com. All complaints will be reviewed and investigated promptly and fairly.
## Attribution
+3 -3
View File
@@ -153,8 +153,8 @@ picpeak/
## 📮 Contact
- Create an [issue](https://github.com/the-luap/picpeak/issues) for bugs or features
- Join [discussions](https://github.com/the-luap/picpeak/discussions) for questions
- Security issues: Open a [security issue](https://github.com/the-luap/picpeak/issues/new?labels=security) on GitHub
- Create an issue for bugs or features
- Join discussions for questions
- Email: picpeak@example.com for security issues
Thank you for contributing! 🎉
+220
View File
@@ -0,0 +1,220 @@
# 🚀 PicPeak Deployment Guide
This guide will help you deploy PicPeak in production. The entire process takes about 10-15 minutes.
## 📋 Prerequisites
- A server with Docker and Docker Compose installed
- A domain name (for SSL certificates)
- SMTP credentials for sending emails
- Basic command line knowledge
## 🏃 Quick Deploy (Recommended)
### 1. Clone and Configure
```bash
# Clone the repository
git clone https://github.com/the-luap/picpeak.git
cd picpeak
# Copy environment template
cp .env.production.example .env
# Generate a secure JWT secret
echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env
# Edit configuration
nano .env
```
### 2. Required Environment Variables
Edit your `.env` file with these essential settings:
```env
# Application URLs
FRONTEND_URL=https://your-domain.com
BACKEND_URL=https://your-domain.com
# Email Configuration (Required for notifications)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=your-email@gmail.com
# Admin Configuration
ADMIN_EMAIL=admin@your-domain.com
ADMIN_PASSWORD=your-secure-password
# Database (PostgreSQL for production)
DATABASE_CLIENT=pg
DB_HOST=postgres
DB_NAME=picpeak
DB_USER=picpeak
DB_PASSWORD=secure-db-password
```
### 3. Deploy with Docker Compose
```bash
# Start all services
docker-compose -f docker-compose.prod.yml up -d
# Check logs
docker-compose logs -f
# Access your site at https://your-domain.com
```
## 🔧 Configuration Options
### Storage Settings
```env
# Storage paths (default: ./storage)
STORAGE_PATH=./storage
ARCHIVE_PATH=./storage/archives
# Gallery expiration (days)
DEFAULT_EXPIRATION_DAYS=30
WARNING_DAYS_BEFORE_EXPIRY=7
```
### Security Settings
```env
# Session timeout (minutes)
SESSION_TIMEOUT=60
# Rate limiting
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
RATE_LIMIT_MAX_REQUESTS=100
```
### Analytics (Optional)
```env
# Umami Analytics
VITE_UMAMI_URL=https://analytics.your-domain.com
VITE_UMAMI_WEBSITE_ID=your-website-id
```
## 🔒 SSL/TLS Setup
The production Docker Compose includes automatic SSL via Let's Encrypt:
1. **Ensure your domain points to your server**
2. **Update nginx configuration**:
```bash
nano nginx/nginx.conf
# Replace your-domain.com with your actual domain
```
3. **Start services** - Certbot will automatically obtain certificates
## 📁 Directory Structure
After deployment, your directory structure will be:
```
picpeak/
├── backend/ # API server
├── frontend/ # React app
├── storage/ # Photo storage
│ ├── events/ # Active galleries
│ │ ├── active/ # Current photos
│ │ └── archived/ # Expired galleries
│ ├── thumbnails/ # Generated thumbnails
│ └── uploads/ # User uploads
├── data/ # Database files
└── logs/ # Application logs
```
## 🔄 Maintenance
### Backup
```bash
# Backup database and photos
./scripts/backup.sh
# Backups are stored in ./backups/
```
### Update
```bash
# Pull latest changes
git pull
# Rebuild and restart
docker-compose -f docker-compose.prod.yml up -d --build
```
### Logs
```bash
# View all logs
docker-compose logs
# View specific service
docker-compose logs backend
docker-compose logs frontend
```
## 🚨 Troubleshooting
### Common Issues
**Photos not appearing:**
- Check storage permissions: `chmod -R 755 storage/`
- Verify file watcher is running: `docker-compose logs backend | grep watcher`
**Email not sending:**
- Test SMTP settings: Admin Panel → Settings → Email → Send Test
- Check email queue: Admin Panel → System → Email Queue
**Can't access admin panel:**
- Default login: Use email/password from `.env`
- Reset password: `docker exec picpeak-backend npm run reset-admin`
### Health Check
```bash
# Check service status
docker-compose ps
# Test backend API
curl https://your-domain.com/api/health
# Check disk space
df -h storage/
```
## 🐳 Alternative Deployment Methods
### Using Docker Swarm
For high availability deployments, see [Docker Swarm Setup](deploy/README.md).
### Manual Installation
If you prefer not to use Docker:
1. Install Node.js 18+
2. Install PostgreSQL
3. Clone repository
4. Install dependencies: `npm install` in both `/backend` and `/frontend`
5. Build frontend: `cd frontend && npm run build`
6. Start services with PM2
## 📞 Support
- 📘 [Documentation](https://github.com/the-luap/picpeak)
- 🐛 [Report Issues](https://github.com/the-luap/picpeak/issues)
- 💬 [Discussions](https://github.com/the-luap/picpeak/discussions)
---
**Need help?** Open an issue on GitHub and we'll assist you!
-888
View File
@@ -1,888 +0,0 @@
# 🚀 PicPeak Deployment Guide
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
## 📋 Table of Contents
- [Quick Start](#-quick-start)
- [Prerequisites](#prerequisites)
- [Configuration](#-configuration)
- [Deployment](#-deployment)
- [First Login](#-first-login)
- [Release Channels](#-release-channels)
- [Reverse Proxy Setup](#-reverse-proxy-setup)
- [External Media Library](#-external-media-library)
- [Maintenance](#-maintenance)
- [Troubleshooting](#-troubleshooting)
## 🚀 Quick Start
### Option 1: Automated Setup Script (Easiest)
For the simplest installation, use our unified setup script:
```bash
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
chmod +x picpeak-setup.sh && \
sudo ./picpeak-setup.sh
```
This script handles Docker/Native installation choice, OS detection, dependencies, database setup, and optional SSL.
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
### Option 2: Docker with Pre-built Images (Recommended)
```bash
# Clone repository for configuration files
git clone https://github.com/the-luap/picpeak.git
cd picpeak
# Copy and configure environment
cp .env.example .env
nano .env # Edit with your values
# Create required directories
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
# Deploy using pre-built images
docker compose -f docker-compose.production.yml up -d
# Check logs
docker compose -f docker-compose.production.yml logs -f
```
**Available image tags:**
| Channel | Tags | Description |
|---------|------|-------------|
| Stable | `stable`, `latest`, `v2.3.0` | Production-ready releases |
| Beta | `beta`, `v2.3.0-beta.1` | Early access to new features |
| Branch | `main`, `beta` | Latest from each branch |
To select a channel, set `PICPEAK_CHANNEL` in your `.env` file (see [Release Channels](#release-channels) section)
### Option 3: Build from Source
```bash
git clone https://github.com/the-luap/picpeak.git
cd picpeak
cp .env.example .env
nano .env # Edit with your values
mkdir -p events/active events/archived data logs backup storage
chmod -R 755 events data logs backup storage
docker compose build
docker compose up -d
```
## Prerequisites
- Docker and Docker Compose installed
- Domain name (for production)
- SMTP server credentials for emails
- At least 2GB RAM and 20GB storage
## 🔧 Configuration
### Essential Environment Variables
Generate secure values:
```bash
# JWT Secret
openssl rand -base64 64
# Database Password (avoid $ character - see warning below)
openssl rand -base64 32 | tr -d '$'
# Redis Password (avoid $ character - see warning below)
openssl rand -base64 32 | tr -d '$'
```
⚠️ **PASSWORD WARNING**: Docker Compose interprets `$` as variable substitution. Either:
- Avoid `$` in passwords (recommended - use the commands above)
- Escape `$` as `$$` (e.g., `Pass$$word` instead of `Pass$word`)
- Quote the entire value: `DB_PASSWORD='Pass$word'` (less reliable)
### Public Landing Page
- `npm run migrate` now seeds three general settings: `general_public_site_enabled`, `general_public_site_html`, and `general_public_site_custom_css` so existing installs stay disabled by default.
- Configure the feature from **Admin → CMS Pages**. The landing page panel exposes the toggle, HTML editor, optional CSS overrides, preview, and a reset-to-default action.
- All HTML and CSS submitted through the UI is sanitized server-side. Scripts, inline event handlers, disallowed attributes, `@import` rules, and `javascript:` URLs are stripped before content is cached or rendered.
- Resetting via the UI (or calling `POST /api/admin/settings/public-site/reset`) restores the bundled template and clears custom CSS.
- The landing page response is cached in-memory. Override the default 60s cache window by setting `PUBLIC_SITE_CACHE_TTL_MS` (milliseconds) in your environment if you need faster cache busting.
- When the toggle is off PicPeak continues to serve the SPA/login redirect at `/`, preserving legacy behaviour until you explicitly enable the feature.
### Backend Configuration (.env)
Update `.env` with:
- `JWT_SECRET` - Authentication secret (REQUIRED - generate a secure random value)
- `DB_PASSWORD` - PostgreSQL password
- `REDIS_PASSWORD` - Redis password
- `SMTP_*` - Email configuration
- **URL Configuration** (for backend CORS):
- `FRONTEND_URL` - Frontend origin (use full URL with scheme, no trailing slash)
- Example (Docker): `http://localhost:3000`
- `ADMIN_URL` - Admin origin (same as `FRONTEND_URL` for Docker; full URL, no trailing slash)
- Example (Docker): `http://localhost:3000`
Notes:
- Do not include trailing `/` (e.g., use `http://host:3000`, not `http://host:3000/`).
- Always include the scheme (`http://` or `https://`).
- The backend compares origins strictly for CORS; malformed values will cause login requests to fail with 500.
#### Authentication Security
- Configure login attempt thresholds from **Admin → Settings → Security**. Defaults are 5 failed attempts per IP within 15 minutes, resulting in a 30 minute lockout.
#### External Database Example
To use an external PostgreSQL instead of the bundled container, set the following in `.env` and ensure the `postgres` service is disabled or removed:
```env
DB_HOST=db.example.com
DB_PORT=5432
DB_USER=picpeak
DB_PASSWORD=change_me
DB_NAME=picpeak_prod
```
Compose uses these values via `env_file: .env`. The backend service also defaults `DB_HOST=${DB_HOST:-postgres}` so if you dont set `DB_HOST` it will use the bundled `postgres` container.
### Frontend Configuration (frontend/.env)
Create `frontend/.env` from `frontend/.env.example`:
```bash
cp frontend/.env.example frontend/.env
```
Update `frontend/.env` with:
- `VITE_API_URL` - Backend API URL
- Docker (pre-built images) and production behind reverse proxy: `/api` (recommended; avoids CORS and matches the frontend Nginx proxy in the image)
- Local dev (Vite): `http://localhost:3001` or `/api` if proxying through a dev proxy
Note: When using pre-built frontend images, runtime container env does not change the already-built JS. Prefer the default `/api` and let the frontend Nginx proxy forward to the backend.
⚠️ **IMPORTANT PORT CONFIGURATION**:
- The frontend runs on port **3000** in Docker (exposed via nginx)
- The backend API runs on port **3001**
- The frontend `.env` file MUST point to the correct backend port (3001)
- Default `.env.example` is configured for Docker deployment
### Email Configuration Examples
#### Gmail
```env
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-specific-password
```
#### SendGrid
```env
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=apikey
SMTP_PASS=your-sendgrid-api-key
```
## 📦 Deployment
### Using Pre-built Images (Fastest)
```bash
# Pull latest images from GitHub Container Registry
docker pull ghcr.io/the-luap/picpeak/backend:latest
docker pull ghcr.io/the-luap/picpeak/frontend:latest
# Start services using production compose file
docker compose -f docker-compose.production.yml up -d
# View running containers
docker compose ps
```
### Building from Source (For Customization)
```bash
# Build images locally
docker compose build
# Or build with no cache for clean build
docker compose build --no-cache
# Start all services
docker compose up -d
# View running containers
docker compose ps
```
### Access Points
By default, services are exposed on:
- Frontend (UI + Admin): http://localhost:3000 (admin at `/admin`)
- Backend/API: http://localhost:3001 (API only; no UI routes)
- PostgreSQL: localhost:5432 (if needed)
- Redis: localhost:6379 (if needed)
### Initial Admin Setup
When deploying for the first time, an admin account is automatically created with a secure, randomly generated password. This password is displayed in the Docker logs during initialization and **must be changed** on first login.
#### Finding the Auto-Generated Admin Password
The admin password is automatically generated during the first startup and displayed in the backend container logs. Here's how to find it:
**Option 1: Search Docker logs for admin password** (recommended)
```bash
# Find the auto-generated admin password in logs
docker compose logs backend | grep "Admin password"
```
You should see output like:
```
✅ Admin password generated: BraveTiger6231!
```
**Option 2: View the complete initialization logs**
```bash
# View the complete admin setup logs
docker compose logs backend | grep -A 10 "Admin user created"
```
**Option 3: Check the saved credentials file**
```bash
# The password is also saved in the backend container
docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt
```
**Option 4: Use the helper script**
```bash
# Show current admin username and email (password is hidden)
docker exec picpeak-backend node scripts/show-admin-credentials.js
# Reset the admin password to a new random password (displays new password in console)
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
```
> **Note:** When using `--reset`, the new password will be displayed in the console output. Save it immediately - it will not be shown again!
#### Important Security Notes
- **Login requires the email address**, not username
- When resetting password, the new password is displayed once in the console - save it immediately
- **Password change is MANDATORY** on first login - the system will force you to change it
- If you lose the password before first login, use the `--reset` option to generate a new one
- New password requirements: minimum 12 characters, mixed case, numbers, and special characters
## 🔐 First Login
After deployment, you must complete the first login process which includes mandatory password change for security.
### Step 1: Locate Your Admin Password
1. **Find the auto-generated password** from the credentials file:
```bash
# Docker deployment
docker compose exec backend cat /app/data/ADMIN_CREDENTIALS.txt
# Or directly from the host (if you have access)
cat data/ADMIN_CREDENTIALS.txt
```
2. **Note the admin email** (default: `admin@example.com` unless customized)
### Step 2: Access Admin Panel
1. Navigate to your frontend domain and open the admin section:
- `http://your-domain.com/admin` (behind reverse proxy)
- `http://localhost:3000/admin` (Docker local)
The backend at `:3001` serves API only and does not serve the admin UI.
2. Login using:
- **Email**: `admin@example.com` (or your custom admin email)
- **Password**: The auto-generated password from the logs
### Step 3: Mandatory Password Change
Upon first login, the system will **automatically redirect** you to change your password:
1. **You cannot skip this step** - it's enforced for security
2. Enter the current auto-generated password
3. Create a new secure password meeting these requirements:
- Minimum 12 characters
- At least one uppercase letter
- At least one lowercase letter
- At least one number
- At least one special character (!@#$%^&*)
### Security Best Practices for New Password
- **Use a unique password** not used elsewhere
- **Consider a password manager** for generation and storage
- **Include mixed characters**: `MySecureP@ssw0rd2024!`
- **Avoid personal information** (names, dates, etc.)
- **Save securely** - you cannot recover this password easily
### If You Lose Access
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
```bash
# Native reinstall example
sudo ./picpeak-setup.sh --native --force-admin-password-reset
# Docker reinstall example
sudo ./picpeak-setup.sh --docker --force-admin-password-reset
```
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
#### Configuring Admin Email
By default, the admin email is `admin@example.com`. To use a different email address, set it in your `.env` file before first deployment:
```env
# .env
ADMIN_EMAIL=your-email@yourdomain.com
```
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
## 🔄 Release Channels
PicPeak offers two release channels for different needs:
### Stable Channel (Recommended)
- Production-ready releases
- Thoroughly tested before release
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
### Beta Channel
- Early access to new features
- May contain bugs or incomplete functionality
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
### Configuring Your Channel
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
```bash
# For stable releases (default)
PICPEAK_CHANNEL=stable
# For beta releases
PICPEAK_CHANNEL=beta
# For a specific version
PICPEAK_CHANNEL=v2.3.0
```
The `docker-compose.production.yml` uses this variable for both backend and frontend images:
```yaml
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
```
### Switching Channels
To switch between channels:
```bash
# Edit your .env file
nano .env
# Change PICPEAK_CHANNEL=stable to PICPEAK_CHANNEL=beta (or vice versa)
# Pull the new images and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
### Update Notifications
The admin dashboard automatically notifies you when updates are available for your channel. This feature:
- Checks GitHub releases hourly (cached to avoid rate limits)
- Shows updates relevant to your current channel (stable or beta)
- Can be disabled by setting `UPDATE_CHECK_ENABLED=false` in your `.env`
## 🔒 Reverse Proxy Setup
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
### Routing Schema
PicPeak consists of two services that need to be routed correctly:
| Path | Service | Port | Description |
|------|---------|------|-------------|
| `/api/*` | Backend | 3001 | All API endpoints |
| `/photos/*` | Backend | 3001 | Protected photo files |
| `/thumbnails/*` | Backend | 3001 | Protected thumbnail files |
| `/uploads/*` | Backend | 3001 | Upload files |
| `/*` (everything else) | Frontend | 3000 | React SPA (including `/admin/*`, `/gallery/*`) |
> **Important:** The `/admin/*` routes are served by the frontend (React SPA), NOT the backend. The backend only handles `/api/admin/*` requests.
### Option 1: Nginx
Install nginx and create `/etc/nginx/sites-available/picpeak`:
```nginx
server {
listen 80;
server_name your-domain.com;
return 301 https://$server_name$request_uri;
}
server {
listen 443 ssl http2;
server_name your-domain.com;
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# Backend: API endpoints
location /api/ {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Backend: Protected media files
location ~ ^/(photos|thumbnails|uploads)/ {
proxy_pass http://localhost:3001;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Frontend: Everything else (React SPA)
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
```
Enable the site:
```bash
sudo ln -s /etc/nginx/sites-available/picpeak /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
```
### Option 2: Traefik
Add labels to `docker-compose.override.yml`:
```yaml
version: '3.8'
services:
frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak.rule=Host(`your-domain.com`)"
- "traefik.http.routers.picpeak.entrypoints=websecure"
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
backend:
labels:
- "traefik.enable=true"
# API endpoints
- "traefik.http.routers.picpeak-api.rule=Host(`your-domain.com`) && PathPrefix(`/api`)"
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3001"
# Protected media files
- "traefik.http.routers.picpeak-media.rule=Host(`your-domain.com`) && (PathPrefix(`/photos`) || PathPrefix(`/thumbnails`) || PathPrefix(`/uploads`))"
- "traefik.http.routers.picpeak-media.entrypoints=websecure"
- "traefik.http.routers.picpeak-media.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-media.loadbalancer.server.port=3001"
```
### Option 3: Caddy
Create a `Caddyfile`:
```caddyfile
your-domain.com {
# Backend: API endpoints
handle /api/* {
reverse_proxy localhost:3001
}
# Backend: Protected media files
handle /photos/* {
reverse_proxy localhost:3001
}
handle /thumbnails/* {
reverse_proxy localhost:3001
}
handle /uploads/* {
reverse_proxy localhost:3001
}
# Frontend: Everything else (React SPA including /admin/*, /gallery/*)
handle {
reverse_proxy localhost:3000
}
}
```
### SSL Certificates
For any reverse proxy, you can use Let's Encrypt:
```bash
# With Certbot
sudo certbot certonly --webroot -w /var/www/certbot -d your-domain.com
# Or use your reverse proxy's built-in ACME support
```
## 📂 External Media Library
The External Media Library allows events to reference photos stored directly on your host filesystem instead of uploading them through the admin UI. This is useful for photographers who already have organized photo libraries and want to share them without re-uploading.
### How It Works
- **Managed mode** (default): Photos are uploaded through the admin UI and stored inside PicPeak's storage directory.
- **Reference mode**: Photos remain on your host filesystem. PicPeak reads them directly and generates thumbnails on demand.
Each event can use either mode. Reference mode events point to a folder under the configured external media root.
### Configuration
Add the following to your `.env` file:
```bash
# Path where your photo library is stored on the host
EXTERNAL_MEDIA_ROOT=/path/to/your/photos
```
Then mount this path into the backend container in your `docker-compose.yml` or `docker-compose.production.yml`:
```yaml
services:
backend:
environment:
- EXTERNAL_MEDIA_ROOT=/external-media
volumes:
- /path/to/your/photos:/external-media:ro # read-only is recommended
```
> **Permissions**: Ensure the container user (`PUID`/`PGID`) has read access to the mounted directory. If thumbnails fail to generate, this is usually a permissions issue.
### Folder Structure
Organize your photos with subdirectories for each event. Within each event folder, use `individual/` and `collages/` subdirectories to classify photos:
```
/path/to/your/photos/
├── wedding-smith-2026/
│ ├── individual/
│ │ ├── IMG_0001.jpg
│ │ ├── IMG_0002.jpg
│ │ └── ...
│ └── collages/
│ ├── group-photo.jpg
│ └── ...
├── corporate-event/
│ ├── individual/
│ │ └── ...
│ └── collages/
│ └── ...
```
Supported file formats: `.jpg`, `.jpeg`, `.png`, `.webp`
### Usage
1. **Create an event** in the admin panel as usual (name, date, email, etc.).
2. **Switch source mode** to "Reference external folder" in the event details under Source Mode.
3. **Browse and select** the external folder using the folder picker that appears. Navigate to the event's directory.
4. **Import photos** by clicking "Import from External Folder" in the Photos tab. PicPeak will:
- Recursively scan the selected folder
- Classify photos by subfolder name (`individual/` or `collages/`)
- Deduplicate by filename (keeps the largest file if duplicates exist)
- Extract image dimensions for gallery layout
- Register the photos in the database
5. **Thumbnails** are generated on demand when a guest first views the gallery. There is no upfront processing delay.
### Limitations
- **Images only** — video files are not supported for external media.
- **Read-only** — PicPeak does not modify or delete files in the external media directory.
- **No automatic sync** — If you add new photos to the external folder, you need to re-import from the admin UI.
- **Backup caveat** — External media originals are excluded from PicPeak's built-in backup system. Only thumbnails and database records are backed up. You are responsible for backing up the source files separately.
### Troubleshooting
| Problem | Solution |
|---------|----------|
| Folder picker shows empty directory | Check that the volume is mounted correctly and `EXTERNAL_MEDIA_ROOT` matches the container path |
| "Permission denied" errors | Ensure `PUID`/`PGID` in `.env` match the owner of the external media files on the host |
| Thumbnails not generating | Verify the backend container can read the files: `docker exec picpeak-backend ls /external-media/your-folder/` |
| Import finds 0 photos | Only `.jpg`, `.jpeg`, `.png`, `.webp` files are supported. Check file extensions. |
## 🔧 Maintenance
### Viewing Logs
```bash
# All services
docker compose logs -f
# Specific service
docker compose logs -f backend
docker compose logs -f frontend
```
### Backup
#### Manual Backup
```bash
# Database backup
docker exec picpeak-postgres pg_dump -U picpeak picpeak_prod > backup/db_$(date +%Y%m%d_%H%M%S).sql
# Files backup
tar -czf backup/photos_$(date +%Y%m%d_%H%M%S).tar.gz events/
```
#### Automated Backup
The application includes a built-in backup service. Configure it in the admin panel:
1. Login to admin panel
2. Go to Settings → Backup
3. Configure destination and schedule
4. Enable backup service
### Updates
#### Method 1: Using Pre-built Images (Recommended)
```bash
# Pull latest changes (for configuration updates)
git pull
# Pull latest images from GitHub Container Registry
docker compose -f docker-compose.production.yml pull
# Restart with new images
docker compose -f docker-compose.production.yml down
docker compose -f docker-compose.production.yml up -d
# Verify services are healthy
docker compose -f docker-compose.production.yml ps
```
#### Method 2: Building from Source
```bash
# Pull latest changes
git pull
# Rebuild and restart
docker compose down
docker compose build --no-cache
docker compose up -d
# Verify services are healthy
docker compose ps
```
#### Specific Version or Channel Updates
To use a specific version or switch channels, update your `.env` file:
```bash
# Edit .env to change the channel or pin to a specific version
nano .env
# Options for PICPEAK_CHANNEL:
# - stable (recommended, production-ready)
# - beta (early access to new features)
# - v2.3.0 (pin to specific stable version)
# - v2.3.0-beta.1 (pin to specific beta version)
# Then pull and restart
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
The admin dashboard will notify you when updates are available for your configured channel.
### Database Migrations
Migrations run automatically on startup, but you can run them manually:
```bash
docker exec picpeak-backend npm run migrate
```
## 🚨 Troubleshooting
### Common Issues
#### 502 Bad Gateway / Login Failures
**This is the most common deployment issue!** Usually caused by misconfigured URLs or network problems:
1. **CORS Configuration Errors**:
```bash
# WRONG - Missing port will cause CORS errors
FRONTEND_URL=http://10.0.252.12
# CORRECT - Include the port you're accessing from
FRONTEND_URL=http://10.0.252.12:3000
```
The backend validates Origin headers against `FRONTEND_URL` for CORS. If they don't match exactly, you'll get 500 errors on login.
2. **After Container Restarts**:
- Nginx may have cached old container IPs
- Solution: `docker restart picpeak-frontend`
- Always wait 30-60 seconds for health checks
3. **Backend Not Starting After Migrations**:
- The logs may only show migrations completed
- Check if server is actually running: `docker exec picpeak-backend ps aux | grep node`
- Should see `node server.js` process
4. **Login After Fresh Install**:
- Check backend logs for auto-generated admin password: `docker compose logs backend | grep "Admin password"`
- Email: `admin@example.com` (or your custom admin email from .env)
- Password: Auto-generated and shown in logs (e.g., `BraveTiger6231!`)
- Remember: Password MUST be changed on first login
5. **Complete Fix Sequence**:
```bash
# 1. Fix your .env file URLs
# 2. Full restart
docker-compose down
docker-compose up -d
# 3. Wait for healthy status
sleep 60
docker ps # All should show (healthy)
# 4. Test backend directly
curl http://localhost:3001/health
# 5. Test through frontend
curl http://localhost:3000/api/public/settings
```
#### Port Already in Use
```bash
# Check what's using the port
sudo lsof -i :3000
sudo lsof -i :3001
# Change ports in .env
FRONTEND_PORT=3002
BACKEND_PORT=3003
```
#### Docker Compose Variable Substitution Errors
If you see warnings like:
```
WARN[0000] The "fgbf" variable is not set. Defaulting to a blank string.
```
This means your password contains `$` which Docker Compose interprets as a variable. Solutions:
1. **Best**: Generate passwords without `$`: `openssl rand -base64 32 | tr -d '$'`
2. **Alternative**: Escape `$` as `$$` in your .env file
3. **Example**: `DB_PASSWORD=Pass@#$$fgbf` instead of `DB_PASSWORD=Pass@#$fgbf`
#### Permission Errors
```bash
# Fix ownership
sudo chown -R 1000:1000 events data logs backup storage
chmod -R 755 events data logs backup storage
```
#### Database Connection Issues
```bash
# Check if database is running
docker compose ps
docker compose logs postgres
# Test connection
docker exec picpeak-postgres pg_isready
```
#### Email Not Sending
- Verify SMTP settings in .env
- Check email queue: `docker exec picpeak-backend psql -U picpeak -d picpeak_prod -c "SELECT * FROM email_queue ORDER BY created_at DESC LIMIT 10;"`
- For Gmail, use app-specific password
- Check logs: `docker compose logs backend | grep email`
### Health Checks
```bash
# Backend health
curl http://localhost:3001/api/health
# Frontend health
curl http://localhost:3000
# Database health
docker exec picpeak-postgres pg_isready
```
### Useful Commands
```bash
# Enter backend container
docker exec -it picpeak-backend sh
# Enter database
docker exec -it picpeak-postgres psql -U picpeak picpeak_prod
# Reset admin password
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
# Check disk usage
df -h
du -sh events/ storage/ backup/
# View running processes
docker compose top
```
## Security Recommendations
1. **Use HTTPS**: Always use a reverse proxy with SSL in production
2. **Firewall**: Only expose necessary ports (80, 443)
3. **Secure passwords**: Use strong, unique passwords for all services
4. **Regular updates**: Keep Docker images and system packages updated
5. **Backup strategy**: Set up automated backups and test restoration
6. **Monitor logs**: Regularly check logs for suspicious activity
7. **Rate limiting**: The app includes built-in rate limiting, configure as needed
## Support
For issues and questions:
- Check logs first: `docker compose logs`
- Review documentation in the repository
- Check existing issues on GitHub
- Create a new issue with:
- Error messages
- Log output
- Environment details (without secrets)
- Steps to reproduce
+98
View File
@@ -0,0 +1,98 @@
# Production Deployment Guide
This guide explains how to deploy PicPeak in production behind a reverse proxy like Traefik.
## Environment Configuration
### Frontend Configuration
For production deployment behind a reverse proxy (Traefik, Nginx, etc.), the frontend should use relative URLs to automatically inherit the protocol (HTTPS) and domain.
1. Copy the production environment template:
```bash
cp frontend/.env.production.example frontend/.env.production
```
2. Set the API URL to use relative path:
```env
# frontend/.env.production
VITE_API_URL=/api
```
This ensures all API calls will use the same domain and protocol as the frontend.
### Backend Configuration
Ensure your backend `.env` file has the correct URLs:
```env
# backend/.env
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com
```
## Docker Compose Production
When using Docker Compose in production:
1. Build with production environment:
```bash
docker-compose -f docker-compose.prod.yml build --build-arg NODE_ENV=production
```
2. The frontend nginx configuration already includes proper proxy settings for:
- `/api` → Backend API
- `/photos` → Protected photo access
- `/thumbnails` → Thumbnail images
- `/uploads` → Public uploads (logos, favicons)
## Traefik Configuration
Example Traefik labels for docker-compose:
```yaml
services:
frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)"
- "traefik.http.routers.picpeak.entrypoints=websecure"
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
```
## Important Notes
1. **No Hardcoded URLs**: The application uses environment variables with relative URL fallbacks, making it production-ready.
2. **HTTPS Only**: When `VITE_API_URL=/api`, all requests will use the same protocol as the page (HTTPS in production).
3. **CORS Configuration**: The backend CORS is configured to accept requests from the URLs specified in `FRONTEND_URL` and `ADMIN_URL`.
4. **Static Assets**: All static assets (photos, thumbnails, uploads) are served through the nginx proxy, inheriting authentication headers.
## Verification
After deployment, verify:
1. Check browser console for any localhost URLs (there should be none)
2. Verify all API calls use HTTPS
3. Check that images load correctly with authentication
4. Test favicon and logo display
## Troubleshooting
If you see console errors about localhost:
1. Ensure `VITE_API_URL=/api` in frontend environment
2. Clear browser cache
3. Rebuild frontend with production environment:
```bash
cd frontend
npm run build
```
If images don't load:
1. Check that nginx proxy locations are configured
2. Verify authentication tokens are being sent
3. Check backend logs for authentication errors
+312
View File
@@ -0,0 +1,312 @@
# Production Deployment Guide
This guide addresses all known production deployment issues and provides solutions.
## Pre-Deployment Checklist
### 1. Environment Variables
Create a `.env` file with ALL required variables:
```bash
# Required
JWT_SECRET=<generate-with-openssl-rand-base64-32>
DB_PASSWORD=<strong-password>
ADMIN_URL=https://yourdomain.com
FRONTEND_URL=https://yourdomain.com
# Database
DB_USER=picpeak
DB_NAME=picpeak
# Email (Optional but recommended)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
EMAIL_FROM=noreply@yourdomain.com
# Umami Analytics (Optional)
UMAMI_URL=https://analytics.yourdomain.com
UMAMI_WEBSITE_ID=your-website-id
UMAMI_HASH_SALT=<generate-random-string>
```
### 2. Generate Secrets
```bash
# Generate JWT Secret
openssl rand -base64 32
# Generate Database Password
openssl rand -base64 24
# Generate Umami Hash Salt
openssl rand -hex 32
```
## Deployment Steps
### 1. Initial Setup
```bash
# Clone repository
git clone https://github.com/the-luap/wedding-photo-sharing.git
cd wedding-photo-sharing
# Create required directories
mkdir -p storage/events/active storage/events/archived storage/thumbnails storage/uploads
mkdir -p data logs
mkdir -p certbot/conf certbot/www
# Set permissions (important!)
chmod -R 755 storage data logs
```
### 2. Fix Docker Volume Permissions
Create `docker-compose.override.yml` for local volume configuration:
```yaml
version: '3.8'
services:
backend:
volumes:
- ./storage:/app/storage:delegated
- ./data:/app/data:delegated
- ./logs:/app/logs:delegated
user: "1001:1001" # nodejs user
db:
volumes:
- ./postgres-data:/var/lib/postgresql/data
```
### 3. Build and Deploy
```bash
# Build images
docker-compose -f docker-compose.prod.yml build
# Start services
docker-compose -f docker-compose.prod.yml up -d
# Check logs
docker-compose -f docker-compose.prod.yml logs -f backend
```
### 4. Create Admin User
After deployment, create the first admin user:
```bash
# Enter backend container
docker-compose -f docker-compose.prod.yml exec backend sh
# Create admin
node scripts/create-admin.js \
--username admin \
--email admin@yourdomain.com \
--password <your-secure-password>
# Exit container
exit
```
### 5. Configure Email (if using database config)
1. Login to admin panel: https://yourdomain.com/admin
2. Go to Settings > Email Configuration
3. Enter SMTP details
4. Test email sending
## Common Issues and Solutions
### Issue 1: Migration Failures
**Error**: "relation already exists"
**Solution**: The safe migration runner handles this automatically. If issues persist:
```bash
# Reset migrations tracking
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak -d picpeak
# In PostgreSQL:
DROP TABLE IF EXISTS migrations;
\q
# Re-run migrations
docker-compose -f docker-compose.prod.yml exec backend npm run migrate:safe
```
### Issue 2: Permission Denied Errors
**Error**: "EACCES: permission denied"
**Solution**: Fix container permissions:
```bash
# Stop containers
docker-compose -f docker-compose.prod.yml down
# Fix permissions on host
sudo chown -R 1001:1001 storage data logs
# Restart
docker-compose -f docker-compose.prod.yml up -d
```
### Issue 3: Database Connection Failed
**Error**: "no pg_hba.conf entry"
**Solution**: Already fixed in docker-compose.prod.yml with:
- SSL disabled for internal Docker network
- Proper authentication method (scram-sha-256)
### Issue 4: Frontend Can't Connect to Backend
**Error**: CORS errors or connection refused
**Solution**: Ensure environment variables match:
- Backend: `FRONTEND_URL` must match your frontend URL
- Frontend: `VITE_API_URL` must be set during build
### Issue 5: Email Not Sending
**Solution**: Check email configuration:
```bash
# Check backend logs
docker-compose -f docker-compose.prod.yml logs backend | grep email
# Verify SMTP settings
# Gmail users: Use app password, not regular password
# Enable "Less secure app access" or use OAuth2
```
## SSL/HTTPS Setup
1. Update `nginx/sites-enabled/default` with your domain
2. Run certbot:
```bash
# Initial certificate
docker-compose -f docker-compose.prod.yml run --rm certbot certonly \
--webroot --webroot-path=/var/www/certbot \
-d yourdomain.com -d www.yourdomain.com
# Auto-renewal is handled by the certbot container
```
## Monitoring
### Health Checks
```bash
# Backend health
curl http://localhost/api/health
# Database connection
docker-compose -f docker-compose.prod.yml exec backend \
psql -U picpeak -d picpeak -c "SELECT 1"
```
### Logs
```bash
# All services
docker-compose -f docker-compose.prod.yml logs -f
# Specific service
docker-compose -f docker-compose.prod.yml logs -f backend
```
## Backup and Restore
### Backup
```bash
#!/bin/bash
# backup.sh
DATE=$(date +%Y%m%d_%H%M%S)
BACKUP_DIR="./backups/$DATE"
mkdir -p $BACKUP_DIR
# Database
docker-compose -f docker-compose.prod.yml exec -T db \
pg_dump -U picpeak picpeak > $BACKUP_DIR/database.sql
# Files
tar -czf $BACKUP_DIR/storage.tar.gz storage/
echo "Backup completed: $BACKUP_DIR"
```
### Restore
```bash
# Database
docker-compose -f docker-compose.prod.yml exec -T db \
psql -U picpeak picpeak < ./backups/20240713_120000/database.sql
# Files
tar -xzf ./backups/20240713_120000/storage.tar.gz
```
## Production Best Practices
1. **Always use named volumes** in production for better data persistence
2. **Set up monitoring** with Prometheus/Grafana
3. **Enable backups** with automated scripts
4. **Use a reverse proxy** (Nginx) for SSL termination
5. **Implement rate limiting** at the Nginx level
6. **Regular updates** - Keep Docker images updated
7. **Log rotation** - Configure log rotation for application logs
## Troubleshooting Commands
```bash
# Check running containers
docker-compose -f docker-compose.prod.yml ps
# Restart a service
docker-compose -f docker-compose.prod.yml restart backend
# View real-time logs
docker-compose -f docker-compose.prod.yml logs -f --tail=100
# Execute commands in container
docker-compose -f docker-compose.prod.yml exec backend sh
# Database shell
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak
# Clean restart
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d
```
## Security Checklist
- [ ] Strong JWT_SECRET (min 32 chars)
- [ ] Strong database password
- [ ] SSL/HTTPS enabled
- [ ] Firewall configured (only 80/443 open)
- [ ] Regular security updates
- [ ] Backup encryption
- [ ] Access logs monitored
- [ ] Rate limiting enabled
- [ ] File upload restrictions configured
## Support
For issues not covered here:
1. Check application logs
2. Review error messages carefully
3. Ensure all environment variables are set
4. Verify file permissions
5. Check Docker daemon logs
+143 -109
View File
@@ -1,152 +1,186 @@
# PicPeak
# 📸 PicPeak - Open Source Photo Sharing for Events
<div align="center">
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
**Self-hosted photo sharing for event photographers.**
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/)
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](https://reactjs.org/)
[Live Demo](https://demo.picpeak.app) · [Deployment Guide](DEPLOYMENT_GUIDE.md) · [Homepage](https://www.picpeak.app)
</div>
---
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
PicPeak lets you create password-protected, time-limited photo galleries for your clients — hosted on your own server. No subscriptions, no storage limits, no third-party access to your photos.
![PicPeak Gallery Preview](https://github.com/the-luap/picpeak/assets/placeholder-hero.png)
> 📸 *Gallery preview will be updated soon with latest interface*
![PicPeak Gallery Preview](docs/screenshot-gallery.png)
## 🌟 Why Choose PicPeak?
## Demo
Unlike expensive SaaS solutions, PicPeak gives you:
Try it out at [demo.picpeak.app](https://demo.picpeak.app).
- **💰 No Monthly Fees** - One-time setup, unlimited galleries
- **🔒 Complete Data Control** - Your photos stay on your server
- **🎨 White-Label Ready** - Full branding customization
- **📱 Mobile-First Design** - Beautiful on all devices
- **🚀 Lightning Fast** - Optimized performance and caching
- **🌍 Multi-Language** - Built-in i18n support (EN, DE)
Admin panel: [demo.picpeak.app/admin](https://demo.picpeak.app/admin) — login with `demo@picpeak.app` / `Demo2026!`
## ✨ Key Features
> The demo resets periodically.
### For Photographers
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
-**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
## Features
### For Clients
- 🖼️ **Beautiful Galleries** - Clean, modern interface
- 📱 **Mobile Optimized** - Swipe through photos on any device
- ⬇️ **Bulk Downloads** - Download all photos with one click
- 🔍 **Smart Search** - Find photos quickly
- 📤 **Guest Uploads** - Optional client photo uploads
**Gallery Management** — Create galleries, upload photos via drag & drop, set passwords and expiration dates. Galleries auto-archive when they expire. Events start as drafts so you can upload and prepare before notifying the client.
### Technical Excellence
- 🐳 **Docker Ready** - Deploy in minutes
- 🔄 **Auto-Processing** - Automatic thumbnail generation
- 💾 **Smart Storage** - Automatic archiving of expired galleries
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
- 📈 **Scalable** - From small studios to large agencies
**Client Experience** — Responsive galleries that look great on any device. Guests can browse, download individual photos or everything at once. Optional guest uploads and feedback (likes, comments, ratings).
## 🚀 Quick Start
**Themes & Branding** — 11 built-in theme presets, custom CSS templates, configurable colors/fonts/layouts. White-label your admin panel and login page with your own logo and company name.
**Email Notifications** — Automated gallery creation, expiration warning, and archive emails. Multilingual templates (EN, DE, NL, PT, RU) editable from the admin UI.
**Photo Protection** — Watermarking, right-click prevention, canvas rendering, DevTools detection. Configurable per gallery.
**External Media** — Reference photos from a mounted folder instead of uploading. PicPeak reads originals in place and generates thumbnails on demand.
**Multi-Language** — Full UI translations for English, German, Dutch, Portuguese, and Russian. Email templates support all languages independently.
**Analytics** — Built-in view/download tracking plus optional Umami integration for privacy-focused analytics.
**Video Support** — Upload and stream MP4, WebM, MOV alongside photos. FFmpeg bundled via npm.
**Multiple Admins** — Role-based access control with super admin, admin, and editor roles.
## Quick Start
Get PicPeak running in under 5 minutes:
```bash
# Clone the repository
git clone https://github.com/the-luap/picpeak.git
cd picpeak
# Copy environment template
cp .env.example .env
# Edit .env — set at least JWT_SECRET and passwords
docker compose up -d
# Edit configuration (required: JWT_SECRET)
nano .env
# Start with Docker Compose
docker-compose up -d
# Access at http://localhost:3005
```
Open `http://localhost:3000` and log in with the credentials from your `.env`.
## 📖 Documentation
> **Permissions:** Set `PUID` and `PGID` in `.env` to match your host user (`id -u` / `id -g`) so Docker volumes are writable.
- 📘 [**Deployment Guide**](DEPLOYMENT.md) - Detailed installation instructions
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
- 📜 [**License**](LICENSE) - MIT License
- 🔒 [**Security**](SECURITY.md) - Security policies
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
See the [Deployment Guide](DEPLOYMENT_GUIDE.md) for reverse proxy setup, SSL, external media, and production configuration.
## 🎯 Use Cases
## Screenshots
Perfect for:
- 💒 **Wedding Photographers** - Share ceremony photos securely
- 🎂 **Event Photography** - Birthday parties, corporate events
- 📸 **Portrait Studios** - Client galleries with download limits
- 🏢 **Corporate Events** - Internal photo sharing with branding
- 🎓 **School Photography** - Secure parent access with expiration
## 🏗️ Tech Stack
- **Backend**: Node.js, Express, SQLite/PostgreSQL
- **Frontend**: React, Tailwind CSS, Framer Motion
- **Storage**: File-based with automatic archiving
- **Email**: SMTP with customizable templates
- **Analytics**: Privacy-focused with Umami integration
## 🤝 Contributing
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
See our [Contributing Guide](CONTRIBUTING.md) for details.
## 📊 Comparison with Alternatives
| Feature | PicPeak | PicDrop | Scrapbook.de |
|---------|---------|---------|--------------|
| Self-Hosted | ✅ | ❌ | ❌ |
| Custom Branding | ✅ Full | Limited | Limited |
| Monthly Cost | $0 | $29-199 | €19-99 |
| Storage Limit | Unlimited* | 50-500GB | 100-1000GB |
| Client Uploads | ✅ | ✅ | ✅ |
| API Access | ✅ | Paid | ❌ |
| Open Source | ✅ | ❌ | ❌ |
*Limited only by your server storage
## 🛡️ Security
PicPeak takes security seriously:
- 🔐 Password hashing with bcrypt
- 🎫 JWT-based authentication
- 🚦 Rate limiting on all endpoints
- 🛡️ CORS protection
- 📝 Activity logging
- 🔒 Secure file access
Found a security issue? Please email security@example.com
## 📸 Screenshots
### 🎛️ **Admin Dashboard**
Get a complete overview of your photo galleries, analytics, and system status.
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
### 📊 **Analytics & Insights**
Track gallery performance, view statistics, and monitor user engagement.
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
### 📁 **Event Management**
Organize and manage your photo galleries with intuitive event management tools.
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
### ✨ **Key Interface Highlights**
<details>
<summary>Admin Dashboard</summary>
<summary>👆 Click to see more interface details</summary>
#### What makes PicPeak's interface special:
- **🎨 Clean Design**: Modern, photographer-friendly interface
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
- **⚡ Fast Loading**: Optimized for quick photo browsing
- **🔒 Secure Access**: Password-protected galleries with expiration
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
<img src="docs/screenshot-dashboard.png" alt="Admin Dashboard" width="800" />
</details>
<details>
<summary>Event Management</summary>
## 🙏 Acknowledgments
<img src="docs/screenshots-events.png" alt="Event Management" width="800" />
</details>
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
<details>
<summary>Analytics</summary>
## 📄 License
<img src="docs/screenshot-analytics.png" alt="Analytics" width="800" />
</details>
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
## Comparison
## 🚀 Ready to Get Started?
| | PicPeak | PicDrop | Scrapbook.de |
|---|---|---|---|
| Self-hosted | Yes | No | No |
| Monthly cost | $0 | $29-199 | 19-99 EUR |
| Storage | Unlimited | 50-500 GB | 100-1000 GB |
| Custom branding | Full | Limited | Limited |
| Open source | Yes | No | No |
| API | Yes | Paid | No |
## Tech Stack
- **Backend:** Node.js, Express, PostgreSQL (or SQLite)
- **Frontend:** React, TypeScript, Tailwind CSS
- **Infrastructure:** Docker, Nginx, Redis
- **Processing:** Sharp (images), FFmpeg (video)
## Release Channels
**Stable** (`stable` / `latest`) — Production-ready. Use this for real deployments.
**Beta** (`beta`) — Early access to new features. May have rough edges.
```bash
# Set in .env
PICPEAK_CHANNEL=stable # or beta
# Update
docker compose -f docker-compose.production.yml pull
docker compose -f docker-compose.production.yml up -d
```
The admin dashboard notifies you when updates are available.
## Contributing
We welcome contributions — bug fixes, features, translations, documentation. See [CONTRIBUTING.md](CONTRIBUTING.md) for setup instructions.
## Documentation
- [Deployment Guide](DEPLOYMENT_GUIDE.md) — Installation, configuration, reverse proxy, external media
- [Admin API (OpenAPI)](docs/picpeak-admin-api.openapi.yaml) — Machine-readable API spec
- [Admin API Quickstart](docs/admin-api-quickstart.md) — Authentication and testing guide
- [Security Policy](SECURITY.md)
## Contributors
Thanks to the people whose code, reports, and feedback have shaped PicPeak:
- [**@Luca-Timo**](https://github.com/Luca-Timo) — native Apple Silicon multi-arch images, external-URL toggle for legal CMS pages, lazy-loaded folder tree picker, admin-email picker, self-hosted webfont system, gallery header/banner decoupling, and several typed-API refactors.
- [**@Rekoo-PS**](https://github.com/Rekoo-PS) — sharp-eyed bug reporter and product feedback. Filed the issues that drove the login-loop fix, gallery-loading skeleton work, mobile-lightbox overhaul, admin-events search-counter fix, photo-count column, and bulk-delete workflow. Also a [BuyMeACoffee](https://buymeacoffee.com/theluap) supporter.
If you've contributed and aren't listed here, please open a PR.
## License
MIT — use it for personal or commercial projects.
1.**Star this repository** to show your support
2. 📖 Read the [Deployment Guide](DEPLOYMENT.md)
3. 🐛 Report issues or request features
4. 🤝 Join our community and contribute!
---
<p align="center">
<a href="https://www.picpeak.app">Homepage</a> · <a href="https://demo.picpeak.app">Live Demo</a> · <a href="DEPLOYMENT_GUIDE.md">Docs</a> · <a href="https://github.com/the-luap/picpeak/issues">Issues</a>
</p>
Made with ❤️ by photographers, for photographers
<br>
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
<a href="DEPLOYMENT.md">Documentation</a> •
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
</p>
+9 -12
View File
@@ -6,8 +6,8 @@ We release patches for security vulnerabilities. Currently supported versions:
| Version | Supported |
| ------- | ------------------ |
| 2.x.x | :white_check_mark: |
| < 2.0 | :x: |
| 1.x.x | :white_check_mark: |
| < 1.0 | :x: |
## Reporting a Vulnerability
@@ -15,14 +15,11 @@ We take the security of PicPeak seriously. If you have discovered a security vul
### 1. **Do NOT create a public GitHub issue**
### 2. Report the vulnerability privately by:
- **Preferred:** Use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- **Alternative:** Email us at **info@picpeak.app** with the details
- Include:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
### 2. Email us at security@example.com with:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
### 3. You can expect:
- Acknowledgment within 48 hours
@@ -82,7 +79,7 @@ We believe in responsible disclosure. Once a vulnerability is fixed:
## Contact
- Security issues: Email **info@picpeak.app** or use [GitHub Private Vulnerability Reporting](https://github.com/the-luap/picpeak/security/advisories/new)
- General support: [GitHub Issues](https://github.com/the-luap/picpeak/issues)
- Security issues: security@example.com
- General support: https://github.com/the-luap/picpeak/issues
Thank you for helping keep PicPeak and its users safe!
-553
View File
@@ -1,553 +0,0 @@
# 🚀 PicPeak Simple Setup Guide
This guide provides easy installation instructions for PicPeak on Linux servers with both Docker and non-Docker options.
## 📋 Quick Start
### One-Line Installation
```bash
# Download and run the unified setup script
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.
## 🎯 Installation Methods
### Method 1: Docker Installation (Recommended)
Best for: Most users, easy updates, isolated environment
```bash
sudo ./picpeak-setup.sh --docker
```
**Pros:**
- ✅ Easier installation and updates
- ✅ Better isolation from system
- ✅ Consistent environment across platforms
- ✅ Built-in PostgreSQL and Redis
**Cons:**
- ❌ Requires more resources (~4GB RAM recommended)
- ❌ Additional Docker overhead
### Method 2: Native Installation
Best for: Resource-constrained systems, Raspberry Pi, direct control
```bash
sudo ./picpeak-setup.sh --native
```
**Pros:**
- ✅ Lower resource usage (~1GB RAM minimum)
- ✅ Direct system control
- ✅ No Docker overhead
- ✅ Better for ARM devices
**Cons:**
- ❌ More complex setup
- ❌ System dependencies required
- ❌ Manual update process
## 📋 System Requirements
### Minimum Requirements
- **OS**: Ubuntu 20.04+, Debian 11+, Fedora 38+, RHEL/CentOS 8+, Raspberry Pi OS
- **RAM**:
- Docker: 2GB minimum (4GB recommended)
- Native: 1GB minimum (2GB recommended)
- **Storage**: 2GB for application + space for photos
- **Network**: Port 3001 (or 80/443 with proxy)
### Supported Platforms
- ✅ Ubuntu 20.04, 22.04, 24.04
- ✅ Debian 11, 12
- ✅ Raspberry Pi OS (32-bit and 64-bit)
- ✅ Fedora 38, 39, 40
- ✅ RHEL/CentOS/Rocky/AlmaLinux 8, 9
## 🛠️ Installation Options
### Interactive Mode (Default)
```bash
sudo ./picpeak-setup.sh
```
The script will prompt you to choose:
1. Installation method (Docker or Native)
2. Admin email and password
3. Domain configuration (optional)
4. Email server settings (optional)
5. SSL/HTTPS setup (optional)
### Unattended Installation
#### Docker with full configuration:
```bash
sudo ./picpeak-setup.sh --docker --unattended \
--domain photos.example.com \
--email admin@example.com \
--admin-password SecurePass123 \
--smtp-host smtp.gmail.com \
--smtp-port 587 \
--smtp-user your-email@gmail.com \
--smtp-pass your-app-password \
--enable-ssl
```
#### Native with minimal configuration:
```bash
sudo ./picpeak-setup.sh --native --unattended \
--email admin@example.com \
--admin-password SecurePass123
```
### Command Line Options
| Option | Description | Example |
|--------|-------------|---------|
| `--docker` | Use Docker installation | `--docker` |
| `--native` | Use native installation | `--native` |
| `--unattended` | Run without prompts | `--unattended` |
| `--domain` | Domain for HTTPS setup | `--domain photos.example.com` |
| `--email` | Admin email address | `--email admin@example.com` |
| `--admin-password` | Set admin password | `--admin-password MySecurePass` |
| `--smtp-host` | SMTP server hostname | `--smtp-host smtp.gmail.com` |
| `--smtp-port` | SMTP server port | `--smtp-port 587` |
| `--smtp-user` | SMTP username | `--smtp-user user@gmail.com` |
| `--smtp-pass` | SMTP password | `--smtp-pass app-password` |
| `--enable-ssl` | Enable HTTPS with Let's Encrypt | `--enable-ssl` |
| `--port` | Custom port (native only) | `--port 8080` |
| `--update` | Update existing installation | `--update` |
| `--uninstall` | Remove installation | `--uninstall` |
| `--help` | Show help message | `--help` |
## 🏗️ What Gets Installed
### Docker Installation
```
~/picpeak/ # Or custom directory
├── docker-compose.yml # Service definitions
├── .env # Configuration
├── storage/
│ └── events/ # Photo storage
│ ├── active/ # Current galleries
│ └── archived/ # Expired galleries
├── logs/ # Application logs
└── backup/ # Backup directory
```
**Services:**
- PicPeak Backend (Node.js application)
- PostgreSQL Database
- Redis Cache
- Nginx Reverse Proxy (optional)
- Background Workers
### Native Installation
```
/opt/picpeak/ # Installation directory
├── backend/ # Application code
├── events/ # Photo storage
│ ├── active/ # Current galleries
│ └── archived/ # Expired galleries
├── logs/ # Application logs
└── config/ # Configuration files
```
**Services (systemd):**
- `picpeak-backend` - Main application
- `picpeak-workers` - Background workers
- `caddy` - Web server (optional)
## 🌐 Access Methods
### Direct Access (Simplest)
- 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:
- `https://your-domain.com` - Gallery frontend
- `https://your-domain.com/admin` - Admin panel
### Behind Existing Proxy
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;
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;
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
### Creating a Gallery
#### Via Admin Panel
1. Login to admin panel at `/admin`
2. Click "Create New Event"
3. Configure settings (name, date, password, customer email)
4. Upload photos via drag & drop in the Photos tab
5. Publish the gallery when ready
#### Adding Photos via File System
> **Important:** You must first create the event in the admin panel. The file watcher only detects new photos for events that already exist in the database. You cannot create a gallery by copying files alone.
Once an event exists, you can add photos by copying them into the event's folder. PicPeak's built-in file watcher will automatically detect the new files, create database records, and generate thumbnails.
```bash
# Docker installation — copy photos into an existing event's folder
cp /path/to/photos/*.jpg ~/picpeak/storage/events/active/<event-slug>/
# Native installation
sudo cp /path/to/photos/*.jpg /opt/picpeak/events/active/<event-slug>/
sudo chown -R picpeak:picpeak /opt/picpeak/events/active/<event-slug>
```
The event slug is visible in the admin panel URL or share link (e.g. `wedding-smith-2024`). Supported formats: `.jpg`, `.jpeg`, `.png`, `.webp`. The file watcher has a 2-second stability delay before processing new files.
### Gallery Structure
```
<event-slug>/
├── collages/ # Group photos (optional subfolder)
├── individual/ # Individual photos (optional subfolder)
└── photo.jpg # Photos at root level also work
```
## 🔧 Service Management
### Docker Installation
```bash
cd ~/picpeak
# Check status
docker compose ps
# View logs
docker compose logs -f
# Stop services
docker compose down
# Start services
docker compose up -d
# Restart services
docker compose restart
# Update PicPeak
docker compose pull
docker compose up -d
```
### Native Installation
```bash
# Check status
sudo systemctl status picpeak-backend
sudo systemctl status picpeak-workers
# View logs
sudo journalctl -u picpeak-backend -f
sudo journalctl -u picpeak-workers -f
# Start services
sudo systemctl start picpeak-backend picpeak-workers
# Stop services
sudo systemctl stop picpeak-backend picpeak-workers
# Restart services
sudo systemctl restart picpeak-backend picpeak-workers
# Update PicPeak
# (reruns migrations to pick up schema fixes for native installs)
sudo ./picpeak-setup.sh --update
```
## ⚙️ Configuration
### Docker Configuration
Edit `~/picpeak/.env`:
```bash
nano ~/picpeak/.env
docker compose restart
```
### Native Configuration
Edit `/opt/picpeak/app/backend/.env`:
```bash
sudo nano /opt/picpeak/app/backend/.env
sudo systemctl restart picpeak-backend
```
### Key Settings
| Setting | Description | Default |
|---------|-------------|---------|
| `JWT_SECRET` | Token signing secret | Auto-generated |
| `ADMIN_EMAIL` | Admin email | admin@example.com |
| `ADMIN_PASSWORD` | Admin password | Auto-generated |
| `PHOTOS_DIR` | Photo storage path | Varies by method |
| `SMTP_ENABLED` | Email notifications | false |
| `DEFAULT_EXPIRY_DAYS` | Gallery expiration | 30 |
## 📧 Email Configuration
### Gmail Setup
1. Enable 2-Factor Authentication
2. Generate App Password
3. Configure:
```env
SMTP_ENABLED=true
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=noreply@yourdomain.com
```
### SendGrid Setup
1. Sign up at sendgrid.com (100 emails/day free)
2. Create API key
3. Configure:
```env
SMTP_ENABLED=true
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_USER=apikey
SMTP_PASS=your-sendgrid-api-key
SMTP_FROM=verified-sender@yourdomain.com
```
## 🔄 Maintenance
### Backups
#### Docker:
```bash
# Backup script included
cd ~/picpeak
./backup.sh
# Manual backup
docker exec picpeak-postgres pg_dump -U picpeak picpeak > backup.sql
tar -czf photos-backup.tar.gz storage/events/
```
#### Native:
```bash
# Database backup
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/
```
### Updates
```bash
# Docker
cd ~/picpeak
docker compose pull
docker compose up -d
# Native
sudo ./picpeak-setup.sh --update
```
### Uninstall
```bash
# Will prompt for confirmation and data removal options
sudo ./picpeak-setup.sh --uninstall
```
## 🐛 Troubleshooting
### Common Issues
#### Service Won't Start
```bash
# Docker
docker compose logs backend
docker compose down && docker compose up -d
# Native
sudo journalctl -u picpeak-backend -n 50
sudo systemctl restart picpeak-backend
```
#### Can't Access Admin Panel
1. Check firewall:
```bash
# Ubuntu/Debian
sudo ufw allow 3001
# RHEL/CentOS
sudo firewall-cmd --add-port=3001/tcp --permanent
sudo firewall-cmd --reload
```
2. Verify service:
```bash
# Docker
curl http://localhost:3001/api/health
# Native
sudo systemctl is-active picpeak-backend
```
#### Photos Not Showing
```bash
# Check permissions (Native)
sudo chown -R picpeak:picpeak /opt/picpeak/events/
sudo chmod -R 755 /opt/picpeak/events/
# Check permissions (Docker)
ls -la ~/picpeak/storage/events/
```
#### Reset Admin Password
```bash
# Docker
docker exec picpeak-backend node scripts/reset-admin-password.js
# Native
cd /opt/picpeak/app/backend
sudo -u picpeak node scripts/reset-admin-password.js
```
> **Note:** The new password will be displayed in the console output and saved to `ADMIN_PASSWORD_RESET.txt`. Save it immediately!
### Getting Help
1. **Check logs:**
- Docker: `docker compose logs -f`
- Native: `sudo journalctl -u picpeak-backend -f`
- Installation: `/tmp/picpeak-setup-*.log`
2. **Documentation:**
- [Full Documentation](https://docs.picpeak.app)
- [Deployment Guide](https://docs.picpeak.app/deployment)
3. **Support:**
- [GitHub Issues](https://github.com/the-luap/picpeak/issues)
- Include: Error messages, system info (`uname -a`), installation method
## 🔒 Security Best Practices
### Essential Security
1. **Change default admin password immediately**
2. **Use HTTPS for production** (Let's Encrypt included)
3. **Configure firewall** (only open necessary ports)
4. **Regular updates** (system and PicPeak)
5. **Automated backups** (configure in admin panel)
### Advanced Security
- Use VPN for admin panel access
- Configure fail2ban for brute force protection
- Enable audit logging
- Regular security scans
- Implement IP whitelisting
## 📊 Performance Optimization
### Docker Optimization
```yaml
# Adjust in docker-compose.yml
services:
backend:
deploy:
resources:
limits:
cpus: '2'
memory: 2G
```
### Native Optimization
```bash
# Increase Node.js memory
echo "NODE_OPTIONS=--max-old-space-size=2048" >> /opt/picpeak/app/backend/.env
sudo systemctl restart picpeak-backend
```
## 🎯 Quick Setup Examples
### Home/Office Network
```bash
# Simple local setup without domain
sudo ./picpeak-setup.sh --native --email admin@local.com
```
### Public Website with HTTPS
```bash
# Full production setup
sudo ./picpeak-setup.sh --docker \
--domain photos.company.com \
--email admin@company.com \
--enable-ssl
```
### Raspberry Pi Setup
```bash
# Optimized for ARM devices
sudo ./picpeak-setup.sh --native \
--port 8080 \
--email pi@local.com
```
## ✅ Post-Installation Checklist
- [ ] Admin password changed
- [ ] Email configuration tested
- [ ] First test gallery created
- [ ] Backup schedule configured
- [ ] Firewall rules applied
- [ ] SSL certificate working (if applicable)
- [ ] Monitoring setup
- [ ] Documentation bookmarked
---
**PicPeak Setup v1.0** | [Documentation](https://github.com/the-luap/picpeak) | [Support](https://github.com/the-luap/picpeak/issues)
+16 -68
View File
@@ -3,91 +3,39 @@
# Application
NODE_ENV=production
PORT=3001
PORT=3000
# Security
# Generate with: openssl rand -base64 32
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long
# Auth cookie Secure flag
# unset - default: follows NODE_ENV (production=true, dev=false)
# true - always set Secure (HTTPS-only cookies; breaks plain-HTTP access)
# false - never set Secure (allows HTTP; cookies not protected on HTTPS)
# auto - decide per request: Secure on HTTPS, not on HTTP
#
# Use COOKIE_SECURE=auto if your deployment is reachable over both HTTPS
# (via a reverse proxy like Nginx Proxy Manager, Traefik, Caddy) AND plain
# HTTP (e.g. LAN access at http://192.168.x.x:3001). The backend reads
# req.secure from Express, which respects the X-Forwarded-Proto header
# when the proxy is in the trust list.
#
# Requirements for auto mode:
# 1. Your reverse proxy MUST send X-Forwarded-Proto: https on HTTPS
# requests. Standard configs for NPM/Traefik/Caddy do this by default.
# 2. The proxy must be on a trusted IP range. By default PicPeak trusts
# loopback and private networks (127.0.0.1, 10.x, 172.16-31.x,
# 192.168.x, link-local). Proxies outside those ranges need custom
# trust proxy configuration.
# COOKIE_SECURE=auto
# Cookie SameSite attribute (Lax | Strict | None). Default: Lax
# COOKIE_SAMESITE=Lax
# Cookie Domain — set this if serving auth cookies across subdomains.
# Leave unset for same-origin setups.
# COOKIE_DOMAIN=.example.com
# URLs (adjust for your domain)
ADMIN_URL=https://photos.example.com
FRONTEND_URL=https://photos.example.com
BACKEND_URL=https://photos.example.com # Or https://api.photos.example.com if separate
# API URL for email assets (logos, images in emails)
# This must be the publicly accessible URL where recipients can load images
# If not set, defaults to http://localhost:3001 which will break images in production emails
API_URL=https://photos.example.com/api
# URLs
ADMIN_URL=https://yourdomain.com
FRONTEND_URL=https://yourdomain.com
# Database Configuration
DATABASE_CLIENT=pg
DB_HOST=localhost
DB_HOST=db
DB_PORT=5432
DB_USER=picpeak
DB_PASSWORD=your-secure-database-password-change-this
DB_PASSWORD=your-secure-database-password
DB_NAME=picpeak
# Email Configuration (Examples for common providers)
# Gmail example:
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=your-email@gmail.com
# SMTP_PASS=your-app-specific-password
# SendGrid example:
SMTP_HOST=smtp.sendgrid.net
# Email Configuration
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=apikey
SMTP_PASS=your-sendgrid-api-key
EMAIL_FROM=noreply@example.com
SMTP_USER=your-smtp-username
SMTP_PASS=your-smtp-password
EMAIL_FROM=noreply@yourdomain.com
# Storage Paths
# IMPORTANT: STORAGE_PATH must be set to avoid file path resolution issues
# Docker deployment:
# Storage Paths (Docker)
STORAGE_PATH=/app/storage
EVENTS_PATH=/app/storage/events
ARCHIVE_PATH=/app/storage/events/archived
# Local development:
# STORAGE_PATH=./storage
# EVENTS_PATH=./storage/events
# ARCHIVE_PATH=./storage/events/archived
# Analytics Backend Configuration (OPTIONAL)
# Used for server-side tracking only
# Primary configuration should be done through Admin UI > Settings > Analytics
# UMAMI_URL=https://analytics.example.com
# UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab
# Analytics (Optional)
UMAMI_URL=https://analytics.yourdomain.com
UMAMI_WEBSITE_ID=your-website-id
# Logging
LOG_LEVEL=info
+8 -31
View File
@@ -1,46 +1,23 @@
FROM node:22-alpine AS builder
# Add build arguments
ARG CACHEBUST=1
ARG BUILD_DATE
ARG VCS_REF
ARG VERSION
# Add labels for GitHub Container Registry
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"
FROM node:18-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies (--omit=dev replaces deprecated --only=production)
RUN npm ci --omit=dev
# Install dependencies
RUN npm ci --only=production
# Copy application files
COPY . .
# Production stage
FROM node:22-alpine
FROM node:18-alpine
WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (OpenSSL, libexpat, BusyBox CVEs)
RUN apk upgrade --no-cache
# Upgrade npm to fix tar, minimatch, brace-expansion CVEs in npm's own deps
# Pin to 10.x to stay compatible with Node 22 Alpine (npm 11.x has dependency issues)
RUN npm install -g npm@10
# Install dumb-init for proper signal handling, postgresql-client for database
# checks, and ffmpeg for video upload support. Alpine's ffmpeg package ships
# both `ffmpeg` and `ffprobe` built natively against musl libc — the npm
# `@ffmpeg-installer/ffmpeg` binary is glibc-built and (a) doesn't reliably
# run on Alpine and (b) only includes ffmpeg, not ffprobe (which the video
# pipeline calls via fluent-ffmpeg.ffprobe()).
RUN apk add --no-cache dumb-init postgresql-client ffmpeg
# Install dumb-init for proper signal handling and postgresql-client for database checks
RUN apk add --no-cache dumb-init postgresql-client
# Create non-root user
RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
@@ -49,8 +26,8 @@ RUN addgroup -g 1001 -S nodejs && adduser -S nodejs -u 1001
COPY --from=builder --chown=nodejs:nodejs /app/node_modules ./node_modules
COPY --chown=nodejs:nodejs . .
# Ensure all source files are readable and wait script is executable
RUN chmod -R a+r /app && chmod +x wait-for-db.sh
# Make wait script executable
RUN chmod +x wait-for-db.sh
# Create necessary directories
RUN mkdir -p storage/events/active storage/events/archived storage/thumbnails data logs && \
+3 -8
View File
@@ -1,14 +1,9 @@
FROM node:20-alpine
FROM node:18-alpine
WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
RUN apk upgrade --no-cache
# Install dumb-init for proper signal handling and ffmpeg for video uploads.
# Alpine's ffmpeg ships both ffmpeg + ffprobe built natively against musl;
# the npm-bundled binary doesn't run reliably on Alpine. Match production.
RUN apk add --no-cache dumb-init ffmpeg
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Copy package files
COPY package*.json ./
-229
View File
@@ -1,229 +0,0 @@
# Enhanced Backup System Test Suite
This directory contains comprehensive tests for the enhanced backup system with S3 support.
## Test Structure
### Unit Tests
- `services/backupService.enhanced.test.js` - Unit tests for the enhanced backup service
- Configuration management
- S3 backup functionality
- Manifest generation
- Error handling and recovery
- Backward compatibility (local and rsync)
- Service lifecycle management
### Integration Tests
- `integration/backup-s3.test.js` - Integration tests for S3 backups
- Real S3/MinIO connection tests
- Full backup process with actual files
- Incremental backup verification
- Manifest storage and retrieval
- Error recovery scenarios
### Manual Integration Test Script
- `../scripts/test-backup-integration.js` - Comprehensive manual testing script
- Can test against MinIO, AWS S3, or any S3-compatible service
- Tests all backup types (S3, local, rsync)
- Performance testing with large files
- Detailed progress reporting
## Running Tests
### Prerequisites
1. **For Unit Tests**: No special setup required, all dependencies are mocked.
2. **For Integration Tests**: Requires a running S3-compatible service (MinIO recommended)
```bash
# Start MinIO using Docker
docker run -d \
-p 9000:9000 \
-p 9001:9001 \
--name minio-test \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio server /data --console-address ":9001"
```
3. **Environment Variables** (for integration tests):
```bash
# Optional - defaults work with local MinIO
export TEST_S3_ENDPOINT=http://localhost:9000
export TEST_S3_ACCESS_KEY=minioadmin
export TEST_S3_SECRET_KEY=minioadmin
# Skip S3 tests if no S3 service available
export SKIP_S3_TESTS=true
```
### Running Unit Tests
```bash
# Run all backup service tests
npm test -- __tests__/services/backupService.enhanced.test.js
# Run specific test suite
npm test -- __tests__/services/backupService.enhanced.test.js -t "S3 Backup Functionality"
# Run with coverage
npm test -- --coverage __tests__/services/backupService.enhanced.test.js
```
### Running Integration Tests
```bash
# Ensure MinIO is running first!
# Run S3 integration tests
npm test -- __tests__/integration/backup-s3.test.js
# Run with verbose output
npm test -- __tests__/integration/backup-s3.test.js --verbose
# Skip S3 tests if needed
SKIP_S3_TESTS=true npm test -- __tests__/integration/backup-s3.test.js
```
### Running Manual Integration Tests
```bash
# Test with local MinIO (default)
node scripts/test-backup-integration.js
# Test with AWS S3
node scripts/test-backup-integration.js \
--endpoint https://s3.amazonaws.com \
--access-key YOUR_ACCESS_KEY \
--secret-key YOUR_SECRET_KEY \
--bucket your-test-bucket
# Test local backup
node scripts/test-backup-integration.js --type local
# Test with cleanup after completion
node scripts/test-backup-integration.js --cleanup
# Verbose output
node scripts/test-backup-integration.js --verbose
```
## Test Coverage
The test suite covers:
### Configuration
- ✅ Database configuration retrieval
- ✅ JSON parsing and error handling
- ✅ Configuration validation
- ✅ Required field validation
### S3 Functionality
- ✅ S3 client initialization
- ✅ Connection testing
- ✅ File upload with progress tracking
- ✅ Large file handling (multipart upload)
- ✅ Metadata and custom headers
- ✅ Error handling and retries
### Backup Process
- ✅ Full backup execution
- ✅ Incremental backup (changed files only)
- ✅ File checksum calculation and comparison
- ✅ Database backup inclusion
- ✅ Archive inclusion toggle
- ✅ File size limits
### Manifest Generation
- ✅ Full manifest generation
- ✅ Incremental manifest with parent reference
- ✅ JSON and YAML format support
- ✅ Manifest validation
- ✅ S3 manifest storage and retrieval
- ✅ Checksum verification
### Error Handling
- ✅ S3 connection failures
- ✅ File read errors
- ✅ Individual file failure recovery
- ✅ Retry logic with exponential backoff
- ✅ Email notifications on failure
- ✅ Concurrent backup prevention
### Backward Compatibility
- ✅ Local directory backup
- ✅ Rsync backup
- ✅ Existing manifest format support
### Service Management
- ✅ Cron job scheduling
- ✅ Service start/stop
- ✅ Manual backup triggering
- ✅ Backup history and status
## Mock Setup
The unit tests use comprehensive mocking:
```javascript
// Database mocking
jest.mock('../../src/database/db');
// S3 client mocking
jest.mock('../../src/services/storage/s3Storage');
// File system mocking
const mockFs = require('mock-fs');
// Cron job mocking
jest.mock('node-cron');
```
## CI/CD Integration
To run tests in CI/CD pipeline:
```yaml
# Example GitHub Actions
- name: Run Unit Tests
run: npm test -- __tests__/services/backupService.enhanced.test.js
- name: Start MinIO
run: |
docker run -d \
-p 9000:9000 \
--name minio-test \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio server /data
- name: Run Integration Tests
run: npm test -- __tests__/integration/backup-s3.test.js
```
## Debugging Tests
```bash
# Run tests in debug mode
node --inspect-brk ./node_modules/.bin/jest __tests__/services/backupService.enhanced.test.js
# Run single test with console output
npm test -- __tests__/services/backupService.enhanced.test.js -t "should perform S3 backup" --verbose
```
## Performance Considerations
- Integration tests create real files and S3 objects
- Each test run creates a unique S3 bucket to avoid conflicts
- Cleanup is automatic but can be disabled for debugging
- Large file tests (10MB+) are included but can be slow
## Adding New Tests
When adding new backup features:
1. Add unit tests to `backupService.enhanced.test.js`
2. Add integration tests to `backup-s3.test.js` if S3-specific
3. Update manual test script for comprehensive testing
4. Ensure mocks are properly configured
5. Document any new environment requirements
@@ -1,184 +0,0 @@
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();
});
});
@@ -1,207 +0,0 @@
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,524 +0,0 @@
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;
const crypto = require('crypto');
// Load services
const backupService = require('../../src/services/backupService');
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
const { db, initializeDatabase: initDb } = require('../../src/database/db');
const logger = require('../../src/utils/logger');
// Test configuration
// Defaults match the dev MinIO container in docker-compose.dev.yml (port 7104).
// Override via TEST_S3_ENDPOINT / TEST_S3_ACCESS_KEY / TEST_S3_SECRET_KEY when running
// against a different S3 endpoint (CI, hosted MinIO, real AWS, etc.).
const TEST_CONFIG = {
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
bucket: 'test-backup-bucket-' + Date.now(),
region: 'us-east-1'
};
describe('S3 Backup Integration Tests', () => {
let s3Client;
let testStoragePath;
let originalEnv;
beforeAll(async () => {
// Skip if no S3 endpoint configured
if (process.env.SKIP_S3_TESTS === 'true') {
console.log('Skipping S3 integration tests (SKIP_S3_TESTS=true)');
return;
}
// Save original environment
originalEnv = { ...process.env };
// Initialize S3 client for test setup
s3Client = new S3Client({
endpoint: TEST_CONFIG.endpoint,
region: TEST_CONFIG.region,
credentials: {
accessKeyId: TEST_CONFIG.accessKeyId,
secretAccessKey: TEST_CONFIG.secretAccessKey
},
forcePathStyle: true
});
// Create test bucket
try {
await s3Client.send(new CreateBucketCommand({ Bucket: TEST_CONFIG.bucket }));
console.log(`Created test bucket: ${TEST_CONFIG.bucket}`);
} catch (error) {
if (error.name !== 'BucketAlreadyOwnedByYou') {
console.error('Failed to create test bucket:', error);
throw error;
}
}
// Schema is expected to already be applied by `npm run migrate` against
// the dev database. db.migrate.latest() can't be used here because
// PicPeak's custom run-migrations.js tracks state in the `migrations`
// table (not knex's `knex_migrations`), so knex would try to re-apply
// every migration and crash on duplicate-table errors.
const ok = await db.schema.hasTable('events')
&& await db.schema.hasTable('app_settings')
&& await db.schema.hasTable('backup_runs');
if (!ok) {
throw new Error('Required tables missing — run `npm run migrate` against the dev DB first.');
}
// Create test storage directory
testStoragePath = path.join(__dirname, '../fixtures/test-storage');
await fs.mkdir(testStoragePath, { recursive: true });
process.env.STORAGE_PATH = testStoragePath;
// Set up test data
await setupTestData();
// Mock logger to reduce noise
if (process.env.UNMOCK_LOGGER !== 'true') {
logger.info = jest.fn();
logger.debug = jest.fn();
logger.warn = jest.fn();
logger.error = jest.fn();
}
});
afterAll(async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
try {
// Clean up S3 bucket
await cleanupS3Bucket();
await s3Client.send(new DeleteBucketCommand({ Bucket: TEST_CONFIG.bucket }));
console.log(`Deleted test bucket: ${TEST_CONFIG.bucket}`);
} catch (error) {
console.error('Failed to cleanup S3 bucket:', error);
}
// Clean up test storage
await fs.rm(testStoragePath, { recursive: true, force: true });
// Restore environment
process.env = originalEnv;
// Close database
await db.destroy();
});
beforeEach(async () => {
if (process.env.SKIP_S3_TESTS === 'true') {
return;
}
// Clean backup tables
await db('backup_runs').del();
await db('backup_file_states').del();
await db('database_backup_runs').del();
// Configure S3 backup settings
await configureS3Backup();
});
afterEach(async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Clean up S3 objects created during test
await cleanupS3Bucket();
});
describe('S3 Connection and Configuration', () => {
it('should successfully connect to S3-compatible storage', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
const s3Adapter = new S3StorageAdapter({
...TEST_CONFIG,
bucket: TEST_CONFIG.bucket,
forcePathStyle: true,
sslEnabled: false
});
const connected = await s3Adapter.testConnection();
expect(connected).toBe(true);
});
it('should validate S3 configuration before backup', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Remove required configuration
await db('app_settings')
.where('setting_key', 'backup_s3_secret_key')
.del();
await backupService.runBackup();
const lastRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
expect(lastRun.status).toBe('failed');
expect(lastRun.error_message).toContain('S3 backup configuration incomplete');
});
});
describe('Full S3 Backup Process', () => {
it('should perform complete S3 backup with all file types', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Run backup
await backupService.runBackup();
// Verify backup run completed
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
expect(backupRun.status).toBe('completed');
// pg driver returns bigint columns as strings; coerce for the size assertion.
expect(Number(backupRun.files_backed_up)).toBeGreaterThan(0);
expect(Number(backupRun.total_size_bytes)).toBeGreaterThan(0);
// Verify files in S3
const s3Objects = await listS3Objects();
expect(s3Objects.length).toBeGreaterThan(0);
// Check for expected file types
const hasPhotos = s3Objects.some(obj => obj.Key.includes('events/active'));
const hasThumbnails = s3Objects.some(obj => obj.Key.includes('thumbnails'));
const hasManifest = s3Objects.some(obj => obj.Key.includes('backup-manifest'));
const hasSummary = s3Objects.some(obj => obj.Key.includes('backup-summary.json'));
expect(hasPhotos).toBe(true);
expect(hasThumbnails).toBe(true);
expect(hasManifest).toBe(true);
expect(hasSummary).toBe(true);
});
it('should handle large file uploads with multipart', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Create a large test file (15MB)
const largeFilePath = path.join(testStoragePath, 'events/active/large-photo.jpg');
const largeFileSize = 15 * 1024 * 1024; // 15MB
const largeFileContent = Buffer.alloc(largeFileSize, 'x');
await fs.writeFile(largeFilePath, largeFileContent);
// Run backup
await backupService.runBackup();
// Verify large file was uploaded
const s3Objects = await listS3Objects();
const largeFileUploaded = s3Objects.some(obj =>
obj.Key.includes('large-photo.jpg') && obj.Size === largeFileSize
);
expect(largeFileUploaded).toBe(true);
});
it('should include database backup when available', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Create a mock database backup
const dbBackupPath = path.join(testStoragePath, 'backups/db-backup.sql');
await fs.mkdir(path.dirname(dbBackupPath), { recursive: true });
await fs.writeFile(dbBackupPath, 'CREATE TABLE test (id INT);');
// Record database backup
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'sqlite',
file_path: dbBackupPath,
file_size_bytes: 100,
checksum: 'test123',
statistics: JSON.stringify({ tables: {} }),
table_checksums: JSON.stringify({})
});
// Configure to include database
await db('app_settings')
.where('setting_key', 'backup_include_database')
.update({ setting_value: 'true' });
// Run backup
await backupService.runBackup();
// Verify database backup in S3
const s3Objects = await listS3Objects();
const hasDbBackup = s3Objects.some(obj => obj.Key.includes('database/db-backup.sql'));
expect(hasDbBackup).toBe(true);
});
});
describe('Incremental Backup', () => {
it('should only upload changed files in incremental backup', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// First backup - full
await backupService.runBackup();
const firstRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
const firstObjectCount = (await listS3Objects()).length;
// Wait a moment to ensure different timestamps
await new Promise(resolve => setTimeout(resolve, 100));
// Modify one file
const modifiedFile = path.join(testStoragePath, 'events/active/event1/photo1.jpg');
await fs.writeFile(modifiedFile, 'modified content');
// Second backup - incremental
await backupService.runBackup();
const secondRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
expect(secondRun.id).not.toBe(firstRun.id);
expect(Number(secondRun.files_backed_up)).toBe(1); // Only modified file
// Check manifest indicates incremental. The current manifest schema
// groups counts under `incremental.changes.*` (added/modified/deleted/
// unchanged + size_difference) — see backupManifest.generateIncrementalManifest.
if (secondRun.manifest_path) {
const manifest = await backupService.getBackupManifest(secondRun.id);
expect(manifest.manifest.incremental).toBeDefined();
expect(manifest.manifest.incremental.changes).toBeDefined();
expect(manifest.manifest.incremental.changes.modified_files_count).toBe(1);
}
});
it('should track file states across backups', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
await backupService.runBackup();
// Check file states are recorded
const fileStates = await db('backup_file_states').select('*');
expect(fileStates.length).toBeGreaterThan(0);
// Verify checksums are stored
const hasChecksums = fileStates.every(state => state.checksum !== null);
expect(hasChecksums).toBe(true);
});
});
describe('S3 Manifest Storage', () => {
it('should upload manifest to S3 and retrieve it', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Configure YAML manifest format
await db('app_settings')
.where('setting_key', 'backup_manifest_format')
.update({ setting_value: '"yaml"' });
await backupService.runBackup();
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
expect(backupRun.manifest_path).toMatch(/^s3:\/\//);
// Retrieve manifest
const { manifest, summary } = await backupService.getBackupManifest(backupRun.id);
expect(manifest).toBeDefined();
expect(manifest.backup.id).toBeDefined();
expect(summary).toContain('BACKUP MANIFEST SUMMARY');
});
it('should validate manifest integrity', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
await backupService.runBackup();
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
const validationResult = await backupService.validateBackupManifest(backupRun.manifest_path);
expect(validationResult.valid).toBe(true);
expect(validationResult.manifest).toBeDefined();
});
});
describe('Error Recovery', () => {
it('should handle S3 connection failures gracefully', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Configure with invalid endpoint
await db('app_settings')
.where('setting_key', 'backup_s3_endpoint')
.update({ setting_value: '"http://invalid-endpoint:9999"' });
await backupService.runBackup();
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
expect(backupRun.status).toBe('failed');
expect(backupRun.error_message).toBeDefined();
});
it('should continue backup despite individual file failures', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Create a file that will be deleted during backup
const tempFile = path.join(testStoragePath, 'events/active/temp.jpg');
await fs.writeFile(tempFile, 'temporary');
// Mock file deletion during backup
const originalUpload = S3StorageAdapter.prototype.upload;
let callCount = 0;
S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) {
callCount++;
if (callCount === 2) {
// Delete the temp file to cause an error
await fs.unlink(tempFile).catch(() => {});
}
return originalUpload.call(this, localPath, s3Key, options);
});
await backupService.runBackup();
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
// Should complete despite one file error
expect(backupRun.status).toBe('completed');
expect(backupRun.files_backed_up).toBeGreaterThan(0);
// Restore original method
S3StorageAdapter.prototype.upload = originalUpload;
});
it('should retry failed uploads with exponential backoff', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Mock S3 upload to fail twice then succeed
const originalUpload = S3StorageAdapter.prototype.upload;
let attemptCount = 0;
S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) {
attemptCount++;
if (attemptCount <= 2) {
const error = new Error('Network timeout');
error.code = 'ETIMEDOUT';
throw error;
}
return originalUpload.call(this, localPath, s3Key, options);
});
await backupService.runBackup();
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
// Should succeed after retries
expect(backupRun.status).toBe('completed');
expect(attemptCount).toBeGreaterThan(2);
// Restore original method
S3StorageAdapter.prototype.upload = originalUpload;
});
});
// Helper functions
async function setupTestData() {
// Create test directory structure
const dirs = [
'events/active/event1',
'events/active/event2',
'events/archived',
'thumbnails',
'uploads'
];
for (const dir of dirs) {
await fs.mkdir(path.join(testStoragePath, dir), { recursive: true });
}
// Create test files
const files = [
{ path: 'events/active/event1/photo1.jpg', content: 'photo1 content' },
{ path: 'events/active/event1/photo2.jpg', content: 'photo2 content' },
{ path: 'events/active/event2/photo3.jpg', content: 'photo3 content' },
{ path: 'events/archived/old-event.zip', content: 'archived content' },
{ path: 'thumbnails/thumb1.jpg', content: 'thumbnail content' },
{ path: 'uploads/logo.png', content: 'logo content' }
];
for (const file of files) {
await fs.writeFile(
path.join(testStoragePath, file.path),
file.content
);
}
}
async function configureS3Backup() {
const settings = [
{ setting_key: 'backup_enabled', setting_value: 'true' },
{ setting_key: 'backup_destination_type', setting_value: '"s3"' },
{ setting_key: 'backup_s3_bucket', setting_value: `"${TEST_CONFIG.bucket}"` },
{ setting_key: 'backup_s3_region', setting_value: `"${TEST_CONFIG.region}"` },
{ setting_key: 'backup_s3_endpoint', setting_value: `"${TEST_CONFIG.endpoint}"` },
{ setting_key: 'backup_s3_access_key', setting_value: `"${TEST_CONFIG.accessKeyId}"` },
{ setting_key: 'backup_s3_secret_key', setting_value: `"${TEST_CONFIG.secretAccessKey}"` },
{ setting_key: 'backup_s3_force_path_style', setting_value: 'true' },
{ setting_key: 'backup_s3_ssl_enabled', setting_value: 'false' },
{ setting_key: 'backup_include_archived', setting_value: 'true' },
{ setting_key: 'backup_incremental', setting_value: 'true' },
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
];
// Schema drift: app_settings has no created_at column anymore and the
// unique constraint is on setting_key alone, not (setting_type, key).
for (const setting of settings) {
await db('app_settings')
.insert({
setting_type: 'backup',
...setting,
updated_at: new Date(),
})
.onConflict('setting_key')
.merge();
}
}
async function listS3Objects() {
const response = await s3Client.send(new ListObjectsV2Command({
Bucket: TEST_CONFIG.bucket
}));
return response.Contents || [];
}
async function cleanupS3Bucket() {
try {
const objects = await listS3Objects();
if (objects.length > 0) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: TEST_CONFIG.bucket,
Delete: {
Objects: objects.map(obj => ({ Key: obj.Key }))
}
}));
}
} catch (error) {
console.error('Failed to cleanup S3 objects:', error);
}
}
});
@@ -1,165 +0,0 @@
const path = require('path');
const fs = require('fs').promises;
const fsSync = require('fs');
const os = require('os');
const crypto = require('crypto');
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
const sharp = require('sharp');
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
const S3StorageBackend = require('../../src/services/storage/S3StorageBackend');
const storageModule = require('../../src/services/storage');
// Stub out the DB so getThumbnailSettings falls into its catch and uses defaults.
jest.mock('../../src/database/db', () => ({
db: () => {
throw new Error('db disabled in this test');
},
}));
const TEST_S3 = {
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
region: 'us-east-1',
};
const skipS3 = process.env.SKIP_S3_TESTS === 'true';
function backendCases() {
const cases = [
{
name: 'LocalFsStorage',
async setup() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-'));
const storage = new LocalFsStorage({ root });
await storage.init();
return { storage, cleanup: () => fs.rm(root, { recursive: true, force: true }) };
},
},
];
if (!skipS3) {
cases.push({
name: 'S3StorageBackend (MinIO)',
async setup() {
const bucket = `picpeak-imgproc-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
const s3Client = new S3Client({
endpoint: TEST_S3.endpoint,
region: TEST_S3.region,
credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey },
forcePathStyle: true,
});
await s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
const storage = new S3StorageBackend({
bucket,
region: TEST_S3.region,
endpoint: TEST_S3.endpoint,
accessKeyId: TEST_S3.accessKeyId,
secretAccessKey: TEST_S3.secretAccessKey,
forcePathStyle: true,
sslEnabled: false,
});
await storage.init();
return {
storage,
async cleanup() {
const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket }));
if (list.Contents?.length) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) },
}));
}
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
},
};
},
});
}
return cases;
}
async function makeSourceJpeg(targetDir, name) {
const localPath = path.join(targetDir, name);
// 800x600 random RGB image so sharp has something realistic to thumbnail.
const width = 800;
const height = 600;
const buf = Buffer.alloc(width * height * 3);
for (let i = 0; i < buf.length; i++) buf[i] = (i * 7) % 256;
await sharp(buf, { raw: { width, height, channels: 3 } })
.jpeg({ quality: 90 })
.toFile(localPath);
return localPath;
}
describe.each(backendCases())('imageProcessor through $name', ({ setup }) => {
let storage;
let cleanup;
let tmpDir;
let imageProcessor;
beforeAll(async () => {
({ storage, cleanup } = await setup());
storageModule.setStorageForTesting(storage);
// Require AFTER setStorageForTesting so the module sees our injection.
delete require.cache[require.resolve('../../src/services/imageProcessor')];
imageProcessor = require('../../src/services/imageProcessor');
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-imgproc-src-'));
}, 30000);
afterAll(async () => {
storageModule.resetStorage();
if (tmpDir) await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
if (cleanup) await cleanup();
});
test('generateThumbnail writes through storage and returns a relative key', async () => {
const src = await makeSourceJpeg(tmpDir, 'sample.jpg');
const key = await imageProcessor.generateThumbnail(src);
expect(key).toBe('thumbnails/thumb_sample.jpg');
expect(await storage.exists(key)).toBe(true);
const stat = await storage.stat(key);
expect(stat.size).toBeGreaterThan(100);
// Verify the bytes are a valid JPEG by re-parsing with sharp on local mode.
if (storage.kind() === 'local') {
const meta = await sharp(storage.resolveLocalPath(key)).metadata();
expect(meta.format).toBe('jpeg');
expect(meta.width).toBeLessThanOrEqual(300);
}
});
test('generateHeroImage writes through storage and returns a relative key', async () => {
const src = await makeSourceJpeg(tmpDir, 'hero-source.jpg');
const key = await imageProcessor.generateHeroImage(src);
expect(key).toBe('heroes/hero_hero-source.jpg');
expect(await storage.exists(key)).toBe(true);
});
test('isThumbnailValid returns true for a good thumbnail and false for nothing', async () => {
const src = await makeSourceJpeg(tmpDir, 'valid-check.jpg');
const key = await imageProcessor.generateThumbnail(src);
expect(await imageProcessor.isThumbnailValid(key)).toBe(true);
expect(await imageProcessor.isThumbnailValid('thumbnails/does-not-exist.jpg')).toBe(false);
});
test('generateVideoPlaceholder writes a thumbnail entirely from buffer', async () => {
const key = await imageProcessor.generateVideoPlaceholder('demo.mp4');
expect(key).toBe('thumbnails/thumb_demo.jpg');
expect(await storage.exists(key)).toBe(true);
});
test('withLocalCopy yields a usable local path on both backends', async () => {
const sourceKey = 'fixture/withlocal.jpg';
const src = await makeSourceJpeg(tmpDir, 'withlocal.jpg');
const buf = await fs.readFile(src);
await storage.put(sourceKey, buf, { contentType: 'image/jpeg' });
const seenSize = await imageProcessor.withLocalCopy(sourceKey, async (localPath) => {
const meta = await sharp(localPath).metadata();
return meta.width;
});
expect(seenSize).toBe(800);
});
});
@@ -1,189 +0,0 @@
const path = require('path');
const fs = require('fs');
const fsp = require('fs').promises;
const os = require('os');
const crypto = require('crypto');
const { Readable } = require('stream');
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
const S3StorageBackend = require('../../src/services/storage/S3StorageBackend');
// MinIO defaults match docker-compose.dev.yml. Override via TEST_S3_* if needed.
const TEST_S3 = {
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:7104',
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
region: 'us-east-1',
};
const skipS3 = process.env.SKIP_S3_TESTS === 'true';
// Build the matrix of backends to test. Local always runs; S3 runs against MinIO
// unless SKIP_S3_TESTS=true (CI default). The same suite runs against both so
// every consumer can rely on identical semantics.
function backendCases() {
const cases = [
{
name: 'LocalFsStorage',
async setup() {
const root = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-storage-'));
const storage = new LocalFsStorage({ root });
await storage.init();
return { storage, cleanup: () => fsp.rm(root, { recursive: true, force: true }) };
},
},
];
if (!skipS3) {
cases.push({
name: 'S3StorageBackend (MinIO)',
async setup() {
const bucket = `picpeak-test-${Date.now()}-${crypto.randomBytes(2).toString('hex')}`;
const s3Client = new S3Client({
endpoint: TEST_S3.endpoint,
region: TEST_S3.region,
credentials: { accessKeyId: TEST_S3.accessKeyId, secretAccessKey: TEST_S3.secretAccessKey },
forcePathStyle: true,
});
await s3Client.send(new CreateBucketCommand({ Bucket: bucket }));
const storage = new S3StorageBackend({
bucket,
region: TEST_S3.region,
endpoint: TEST_S3.endpoint,
accessKeyId: TEST_S3.accessKeyId,
secretAccessKey: TEST_S3.secretAccessKey,
forcePathStyle: true,
sslEnabled: false,
});
await storage.init();
return {
storage,
async cleanup() {
// Empty bucket then delete it.
const list = await s3Client.send(new ListObjectsV2Command({ Bucket: bucket }));
if (list.Contents?.length) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: bucket,
Delete: { Objects: list.Contents.map((o) => ({ Key: o.Key })) },
}));
}
await s3Client.send(new DeleteBucketCommand({ Bucket: bucket }));
},
};
},
});
}
return cases;
}
async function readToString(stream) {
const chunks = [];
for await (const chunk of stream) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return Buffer.concat(chunks).toString('utf-8');
}
describe.each(backendCases())('StorageBackend contract: $name', ({ setup }) => {
let storage;
let cleanup;
beforeAll(async () => {
({ storage, cleanup } = await setup());
}, 30000);
afterAll(async () => {
if (cleanup) await cleanup();
});
test('put + get + exists + stat + delete round-trip with a buffer body', async () => {
const key = 'photos/event-a/IMG_0001.jpg';
const body = Buffer.from('hello picpeak');
await storage.put(key, body, { contentType: 'image/jpeg' });
expect(await storage.exists(key)).toBe(true);
const stat = await storage.stat(key);
expect(stat).not.toBeNull();
expect(stat.size).toBe(body.length);
const stream = await storage.get(key);
const text = await readToString(stream);
expect(text).toBe('hello picpeak');
await storage.delete(key);
expect(await storage.exists(key)).toBe(false);
expect(await storage.stat(key)).toBeNull();
});
test('put accepts a Readable stream body', async () => {
const key = 'photos/event-b/streamed.bin';
const body = Readable.from(Buffer.from('streamed payload'));
await storage.put(key, body);
const got = await readToString(await storage.get(key));
expect(got).toBe('streamed payload');
});
test('putFromFile + getToFile round-trip', async () => {
const tmpIn = path.join(os.tmpdir(), `in-${Date.now()}.txt`);
const tmpOut = path.join(os.tmpdir(), `out-${Date.now()}.txt`);
await fsp.writeFile(tmpIn, 'file payload');
const key = 'thumbnails/thumb_x.jpg';
await storage.putFromFile(key, tmpIn, { contentType: 'image/jpeg' });
await storage.getToFile(key, tmpOut);
const text = await fsp.readFile(tmpOut, 'utf-8');
expect(text).toBe('file payload');
await fsp.unlink(tmpIn).catch(() => {});
await fsp.unlink(tmpOut).catch(() => {});
});
test('list returns entries under a prefix with size + key', async () => {
await storage.put('events/active/a/photo1.jpg', Buffer.from('a1'));
await storage.put('events/active/a/photo2.jpg', Buffer.from('a22'));
await storage.put('events/active/b/photo3.jpg', Buffer.from('b333'));
const entries = await storage.list('events/active/a');
const keys = entries.map((e) => e.key).sort();
expect(keys).toEqual(['events/active/a/photo1.jpg', 'events/active/a/photo2.jpg']);
const sizes = Object.fromEntries(entries.map((e) => [e.key, e.size]));
expect(sizes['events/active/a/photo1.jpg']).toBe(2);
expect(sizes['events/active/a/photo2.jpg']).toBe(3);
});
test('rename moves an object from src to dst (atomic on local; copy+delete on s3)', async () => {
await storage.put('uploads/temp.jpg', Buffer.from('rename-me'));
await storage.rename('uploads/temp.jpg', 'uploads/final.jpg');
expect(await storage.exists('uploads/temp.jpg')).toBe(false);
expect(await storage.exists('uploads/final.jpg')).toBe(true);
const text = await readToString(await storage.get('uploads/final.jpg'));
expect(text).toBe('rename-me');
});
test('copy duplicates an object without removing the source', async () => {
await storage.put('events/source.jpg', Buffer.from('src'));
await storage.copy('events/source.jpg', 'events/copied.jpg');
expect(await storage.exists('events/source.jpg')).toBe(true);
expect(await storage.exists('events/copied.jpg')).toBe(true);
});
test('delete on a missing key is a no-op (does not throw)', async () => {
await expect(storage.delete('does/not/exist.jpg')).resolves.toBeUndefined();
});
test('stat on a missing key returns null', async () => {
expect(await storage.stat('still/not/here.jpg')).toBeNull();
});
test('rejects path traversal attempts', async () => {
await expect(storage.put('../escape.txt', Buffer.from('x'))).rejects.toThrow(/traversal/i);
await expect(storage.get('../escape.txt')).rejects.toThrow(/traversal/i);
});
});
@@ -1,239 +0,0 @@
// Worker reads WEBHOOK_ALLOW_PRIVATE_URLS at module-load. Set it BEFORE
// requiring the worker so the local-stub URLs (127.0.0.1:<random>) pass
// the SSRF check by default.
process.env.WEBHOOK_ALLOW_PRIVATE_URLS = 'true';
process.env.WEBHOOK_DELIVERY_INTERVAL_MS = '50';
const http = require('http');
const { db } = require('../../src/database/db');
const webhookService = require('../../src/services/webhookService');
const { __test, startWebhookDeliveryWorker, stopWebhookDeliveryWorker } = require('../../src/services/webhookDeliveryWorker');
// Local-only test stub: matches what dev/webhook-receiver/server.js does
// in the docker-compose flow but spun up inside the Jest process so the
// suite is self-contained.
function makeStub({ status = 200, delayMs = 0, bodyOverride = null } = {}) {
const requests = [];
const server = http.createServer(async (req, res) => {
const chunks = [];
for await (const c of req) chunks.push(c);
const body = Buffer.concat(chunks).toString('utf8');
requests.push({ method: req.method, url: req.url, headers: req.headers, body });
if (delayMs) await new Promise((r) => setTimeout(r, delayMs));
res.writeHead(status, { 'Content-Type': 'text/plain' });
res.end(bodyOverride !== null ? bodyOverride : (status >= 200 && status < 300 ? 'ok' : 'forced'));
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
resolve({ url: `http://127.0.0.1:${port}/`, requests, close: () => new Promise((r) => server.close(r)) });
});
});
}
async function insertWebhook(url, events = ['event.published'], extras = {}) {
// Tests need the WORKER to bypass SSRF on 127.0.0.1 stubs, but the
// route layer's allowlist check is bypassed here since we insert
// straight into the DB.
const { plaintext, preview } = webhookService.generateSecret();
const insert = await db('webhooks').insert({
name: extras.name || 'test',
url,
secret: plaintext,
secret_preview: preview,
events: JSON.stringify(events),
active: extras.active !== false,
created_by: 1,
}).returning('id');
const id = insert[0]?.id || insert[0];
return { id, secret: plaintext };
}
async function clearWebhooks() {
await db('webhook_deliveries').del();
await db('webhooks').del();
}
describe('webhook delivery worker (#327)', () => {
beforeAll(async () => {
// Schema is expected to already be applied by `npm run migrate`. We
// just verify the webhooks tables exist; if not, the test harness has
// missed running migration 082.
const ok = await db.schema.hasTable('webhooks');
if (!ok) throw new Error('webhooks table missing — run `npm run migrate` first');
}, 30000);
afterAll(async () => {
stopWebhookDeliveryWorker();
await db.destroy();
});
beforeEach(async () => {
await clearWebhooks();
});
test('signs the body with HMAC-SHA256 and the receiver can verify', async () => {
const stub = await makeStub({ status: 200 });
try {
const { id, secret } = await insertWebhook(stub.url);
await webhookService.fire('event.published', { event: { id: 1, slug: 'sig-test' } });
await __test.tick();
expect(stub.requests).toHaveLength(1);
const got = stub.requests[0];
const sig = got.headers['x-picpeak-signature'];
expect(sig).toBeTruthy();
// Receiver-side verification using the SAME helper we ship in the README.
expect(webhookService.verifySignature(secret, got.body, sig)).toBe(true);
// Tampering must fail.
expect(webhookService.verifySignature(secret, got.body + 'x', sig)).toBe(false);
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('success');
expect(row.attempt_count).toBe(1);
expect(row.response_status).toBe(200);
expect(row.latency_ms).toBeGreaterThanOrEqual(0);
} finally {
await stub.close();
}
});
test('headers include event type and a unique delivery id', async () => {
const stub = await makeStub({ status: 200 });
try {
await insertWebhook(stub.url, ['photo.uploaded']);
await webhookService.fire('photo.uploaded', { photo: { id: 7 } });
await __test.tick();
const got = stub.requests[0];
expect(got.headers['x-picpeak-event']).toBe('photo.uploaded');
expect(got.headers['x-picpeak-delivery']).toBeTruthy();
expect(got.headers['user-agent']).toMatch(/PicPeak-Webhooks/);
} finally {
await stub.close();
}
});
test('on 5xx, schedules a retry with exponential backoff and stays pending', async () => {
const stub = await makeStub({ status: 500 });
try {
const { id } = await insertWebhook(stub.url);
await webhookService.fire('event.published', { event: { id: 2 } });
await __test.tick();
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('pending');
expect(row.attempt_count).toBe(1);
expect(row.response_status).toBe(500);
// BACKOFF_MS[0] = 60s; next_retry_at should be ~60s in the future.
const dueIn = new Date(row.next_retry_at).getTime() - Date.now();
expect(dueIn).toBeGreaterThan(50_000);
expect(dueIn).toBeLessThan(70_000);
} finally {
await stub.close();
}
});
test('after MAX_ATTEMPTS failures, status flips to failed and the row is closed', async () => {
const stub = await makeStub({ status: 500 });
try {
const { id } = await insertWebhook(stub.url);
// Pre-seed a delivery already at attempt_count = 4 so a single tick
// takes it to 5 → failed (avoids waiting through backoffs).
await db('webhook_deliveries').insert({
webhook_id: id,
event_type: 'event.published',
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
attempt_count: 4,
status: 'pending',
next_retry_at: new Date(),
created_at: new Date(),
});
await __test.tick();
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('failed');
expect(row.attempt_count).toBe(5);
expect(row.completed_at).toBeTruthy();
expect(row.next_retry_at).toBeNull();
} finally {
await stub.close();
}
});
test('truncates response body to 1KB before storing', async () => {
const big = 'x'.repeat(5000);
const stub = await makeStub({ status: 200, bodyOverride: big });
try {
const { id } = await insertWebhook(stub.url);
await webhookService.fire('event.published', { event: {} });
await __test.tick();
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('success');
expect(Buffer.byteLength(row.response_body || '', 'utf8')).toBeLessThanOrEqual(1024);
} finally {
await stub.close();
}
});
test('does not deliver to disabled webhooks (post-mortem state captured)', async () => {
const stub = await makeStub({ status: 200 });
try {
const { id } = await insertWebhook(stub.url, ['event.published'], { active: false });
// fire enqueues regardless of active state at fire-time, but we
// disabled BEFORE firing so nothing is enqueued. Direct insert to
// exercise the worker's mid-flight disable check:
await db('webhook_deliveries').insert({
webhook_id: id,
event_type: 'event.published',
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
attempt_count: 0,
status: 'pending',
next_retry_at: new Date(),
created_at: new Date(),
});
await __test.tick();
expect(stub.requests).toHaveLength(0);
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('failed');
expect(row.last_error).toMatch(/disabled/i);
} finally {
await stub.close();
}
});
test('rejects loopback URLs when WEBHOOK_ALLOW_PRIVATE_URLS=false', async () => {
__test.setAllowPrivateUrls(false);
try {
const { id } = await insertWebhook('http://127.0.0.1:9/');
await db('webhook_deliveries').insert({
webhook_id: id,
event_type: 'event.published',
payload: JSON.stringify({ id: 'd1', type: 'event.published', data: {} }),
attempt_count: 0,
status: 'pending',
next_retry_at: new Date(),
created_at: new Date(),
});
await __test.tick();
const row = await db('webhook_deliveries').where({ webhook_id: id }).first();
expect(row.status).toBe('failed');
expect(row.last_error).toMatch(/private|internal/i);
} finally {
__test.setAllowPrivateUrls(true);
}
});
test('worker can be started + stopped without leaking timers', async () => {
startWebhookDeliveryWorker();
startWebhookDeliveryWorker(); // idempotent
stopWebhookDeliveryWorker();
stopWebhookDeliveryWorker(); // idempotent
// If timers leaked the test runner would warn after force-exit; assertion
// is just "no throw".
expect(true).toBe(true);
});
});
@@ -1,112 +0,0 @@
/**
* Unit test for the non-mutating isSessionExpired() helper added to
* middleware/sessionTimeout.js. Used by GET /auth/session to mirror the
* timeout enforcement that sessionTimeoutMiddleware applies to /api/admin
* endpoints — closing the asymmetry that surfaced as the redirect-loop
* recurrence on v3.39.1-beta.0 (issue #350).
*
* The helper has two branches:
* 1. In-memory `lastActivity` exists for this token → expired iff
* now - lastActivity > timeout.
* 2. No in-memory entry (post-restart, or first request) → expired
* iff token's iat is older than the timeout (post-restart guard
* that the existing middleware already implements at line ~101).
*
* Both branches must NOT mutate the in-memory `sessions` Map — the
* middleware is the only place that tracks activity. We assert that.
*/
jest.mock('../../src/database/db', () => ({
db: () => ({
where: () => ({
first: () => ({
timeout: () => Promise.resolve(null),
}),
}),
}),
}));
// Speed up the cached-timeout reads. The module reads
// `security_session_timeout_minutes` from app_settings and falls back to
// DEFAULT_SESSION_TIMEOUT (60 min) when the row is null.
const SIXTY_MINUTES_MS = 60 * 60 * 1000;
const sessionTimeout = require('../../src/middleware/sessionTimeout');
const { isSessionExpired } = sessionTimeout;
function makeDecodedToken({ id = 1, iatSecondsAgo = 0 } = {}) {
return { id, iat: Math.floor((Date.now() - iatSecondsAgo * 1000) / 1000) };
}
describe('isSessionExpired (sessionTimeout helper)', () => {
it('returns false for a freshly-issued token with no in-memory record', async () => {
const decoded = makeDecodedToken({ id: 1, iatSecondsAgo: 60 });
expect(await isSessionExpired('fresh-token-1', decoded)).toBe(false);
});
it('returns true when iat is older than the timeout (post-restart guard)', async () => {
const decoded = makeDecodedToken({
id: 2,
// 90 minutes > 60 minute default timeout
iatSecondsAgo: 90 * 60,
});
expect(await isSessionExpired('stale-token-2', decoded)).toBe(true);
});
it('returns false / true based on lastActivity when one exists', async () => {
// Drive the in-memory map by running the actual middleware once to
// record activity for the token, then check the helper.
const decoded = makeDecodedToken({ id: 3 });
// Drive the actual middleware once with a real signed token so it
// records this token in the in-memory `sessions` Map. Then check the
// helper sees that recent activity and reports "not expired".
const res = { status: jest.fn(() => res), json: jest.fn() };
const jwt = require('jsonwebtoken');
process.env.JWT_SECRET = 'session-timeout-helper-test-secret';
const realToken = jwt.sign(decoded, process.env.JWT_SECRET, {
issuer: 'picpeak-auth',
});
const realReq = {
headers: { authorization: `Bearer ${realToken}` },
cookies: {},
};
await sessionTimeout.sessionTimeoutMiddleware(realReq, res, () => {});
const decodedReal = jwt.decode(realToken);
// Just-recorded → not expired
expect(await isSessionExpired(realToken, decodedReal)).toBe(false);
});
it('returns false when token / decoded is missing (defensive)', async () => {
expect(await isSessionExpired(null, { id: 1 })).toBe(false);
expect(await isSessionExpired('tok', null)).toBe(false);
expect(await isSessionExpired('tok', {})).toBe(false);
});
// Sanity: the helper must not poke the `sessions` Map. Indirectly check
// by counting active sessions before/after a call with a never-seen
// token — should not change.
it('does not mutate the in-memory sessions map', async () => {
const before = sessionTimeout.getActiveSessions();
await isSessionExpired('never-seen-token-99', makeDecodedToken({ id: 99 }));
const after = sessionTimeout.getActiveSessions();
expect(after).toBe(before);
});
it('uses the default 60-minute timeout when no DB setting exists', async () => {
// 59 minutes → not expired
const fresh = makeDecodedToken({ id: 4, iatSecondsAgo: 59 * 60 });
expect(await isSessionExpired('fresh-4', fresh)).toBe(false);
// 61 minutes → expired (just past the default)
const stale = makeDecodedToken({ id: 5, iatSecondsAgo: 61 * 60 });
expect(await isSessionExpired('stale-5', stale)).toBe(true);
});
// Document the constant the test relies on so a future timeout change
// makes this assertion explicit rather than mysterious.
it('default timeout is 60 minutes (constant under test)', () => {
expect(SIXTY_MINUTES_MS).toBe(60 * 60 * 1000);
});
});
@@ -1,395 +0,0 @@
/**
* Regression test for the /admin/login → /admin/dashboard → /admin/login
* redirect loop reported on v3.32.4-beta.0.
*
* Cause: GET /auth/session was less strict than the adminAuth middleware.
* The session endpoint accepted tokens that the protected endpoints
* subsequently rejected with 401, which the frontend's interceptor
* translated into a hard redirect to /admin/login. /auth/session then
* said "valid: true" again on the next page load and the cycle closed.
*
* /auth/session must reject the same admin tokens adminAuth would
* reject, specifically: deactivated admin user, deleted admin user,
* password changed since iat. Same for gallery: archived event.
*/
const express = require('express');
const request = require('supertest');
const jwt = require('jsonwebtoken');
process.env.JWT_SECRET = 'session-symmetry-test-secret';
const fakeDb = {
adminUsers: [],
events: [],
revokedTokens: [],
};
jest.mock('../../src/database/db', () => {
const formatBoolean = (v) => (v ? 1 : 0);
void formatBoolean;
function dbFn(table) {
if (table === 'admin_users') {
let rowFilter = () => true;
return {
where(criteria) {
rowFilter = (row) => {
return Object.entries(criteria).every(([k, v]) => {
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
return row[k] === v;
});
};
return this;
},
select(...cols) {
this._cols = cols;
return this;
},
async first() {
const row = fakeDb.adminUsers.find(rowFilter);
if (!row) return undefined;
if (!this._cols) return row;
const out = {};
for (const c of this._cols) out[c] = row[c];
return out;
},
};
}
if (table === 'events') {
let rowFilter = () => true;
return {
where(criteria) {
rowFilter = (row) =>
Object.entries(criteria).every(([k, v]) => {
if (k === 'is_active') return Boolean(row.is_active) === Boolean(v);
if (k === 'is_archived') return Boolean(row.is_archived) === Boolean(v);
return row[k] === v;
});
return this;
},
async first() {
return fakeDb.events.find(rowFilter);
},
};
}
throw new Error(`Unexpected table: ${table}`);
}
return { db: dbFn, formatBoolean: () => 1 };
});
jest.mock('../../src/utils/dbCompat', () => ({
formatBoolean: (v) => (v ? 1 : 0),
}));
jest.mock('../../src/utils/tokenRevocation', () => ({
isTokenRevoked: jest.fn(async (decoded) => fakeDb.revokedTokens.includes(decoded.id)),
revokeToken: jest.fn(),
}));
jest.mock('../../src/utils/tokenUtils', () => ({
getAdminTokenFromRequest: (req) => {
const auth = req.headers.authorization;
if (auth && auth.startsWith('Bearer ')) return auth.slice(7);
return null;
},
getGalleryTokenFromRequest: () => null,
setAdminAuthCookie: jest.fn(),
setGalleryAuthCookies: jest.fn(),
clearAdminAuthCookie: jest.fn(),
clearGalleryAuthCookies: jest.fn(),
buildCookieOptionsWithExpiry: () => ({}),
}));
jest.mock('../../src/services/recaptcha', () => ({ verifyRecaptcha: () => Promise.resolve(true) }));
// Mock sessionTimeout's isSessionExpired so each test controls the return.
// Default: not expired (so existing tests keep passing without setup).
jest.mock('../../src/middleware/sessionTimeout', () => ({
endSession: jest.fn(),
isSessionExpired: jest.fn(() => Promise.resolve(false)),
}));
jest.mock('../../src/utils/logger', () => ({
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
}));
const authRouter = require('../../src/routes/auth');
function makeApp() {
const app = express();
app.use(express.json());
app.use('/auth', authRouter);
return app;
}
function signAdminToken({ id = 1, username = 'admin', iat, exp }) {
const issuedAt = iat ?? Math.floor(Date.now() / 1000);
// Note: do NOT pass noTimestamp:true here — that strips iat from the
// payload entirely, defeating the password-change comparison. Provide
// iat (and exp) via the payload directly instead.
return jwt.sign(
{ id, username, type: 'admin', iat: issuedAt, exp: exp ?? issuedAt + 3600 },
process.env.JWT_SECRET,
{ issuer: 'picpeak-auth' }
);
}
function signGalleryToken({ eventId = 100, eventSlug = 'wedding' } = {}) {
return jwt.sign(
{ eventId, eventSlug, type: 'gallery' },
process.env.JWT_SECRET,
{ expiresIn: '1h', issuer: 'picpeak-auth' }
);
}
describe('GET /auth/session — symmetry with protected middleware', () => {
beforeEach(() => {
fakeDb.adminUsers = [];
fakeDb.events = [];
fakeDb.revokedTokens = [];
});
it('returns valid:true for an active admin token', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: true,
password_changed_at: null,
});
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(res.body.type).toBe('admin');
});
it('returns valid:false when the admin user has been deactivated', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: false,
password_changed_at: null,
});
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:false when the admin user no longer exists', async () => {
// adminUsers is empty
const token = signAdminToken({ id: 999 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:false when password was changed after the token was issued', async () => {
// iat must be in the past, exp must be in the future so jwt.verify
// doesn't reject the token before /auth/session even gets to look
// at password_changed_at.
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60; // 1 min ago
const tokenExp = tokenIssuedAt + 86400;
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: true,
password_changed_at: new Date((tokenIssuedAt + 30) * 1000), // 30s after iat
});
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:true when password was changed BEFORE the token was issued', async () => {
const tokenIssuedAt = Math.floor(Date.now() / 1000) - 60;
const tokenExp = tokenIssuedAt + 86400;
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
email: 'a@b.com',
is_active: true,
password_changed_at: new Date((tokenIssuedAt - 3600) * 1000), // 1h before iat
});
const token = signAdminToken({ id: 1, iat: tokenIssuedAt, exp: tokenExp });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
it('returns valid:false for a gallery token whose event is archived', async () => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: true,
expires_at: null,
});
const token = signGalleryToken();
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:false for a gallery token whose event is expired', async () => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: false,
expires_at: new Date(Date.now() - 86400_000),
});
const token = signGalleryToken();
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
});
it('returns valid:true for an active gallery token', async () => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: false,
expires_at: new Date(Date.now() + 86400_000),
});
const token = signGalleryToken();
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
it('returns valid:false when the token is revoked', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
is_active: true,
password_changed_at: null,
});
fakeDb.revokedTokens.push(1);
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(401);
expect(res.body.valid).toBe(false);
});
// Session-timeout symmetry — issue #350 recurrence on v3.39.1-beta.0.
// sessionTimeoutMiddleware (mounted on /api/admin) rejects idle/old-iat
// tokens with 401 SESSION_TIMEOUT, but /auth/session previously didn't.
// The new isSessionExpired helper closes that asymmetry.
describe('session-timeout symmetry', () => {
const { isSessionExpired } = require('../../src/middleware/sessionTimeout');
beforeEach(() => {
isSessionExpired.mockReset();
// Default to "active session" so the other admin checks above also
// pass when this branch runs.
isSessionExpired.mockResolvedValue(false);
});
it('returns valid:false when isSessionExpired reports the token has timed out', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
is_active: true,
password_changed_at: null,
});
isSessionExpired.mockResolvedValue(true);
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(false);
expect(res.body.error).toBe('Session expired');
});
it('returns valid:true for an active admin token (helper says not expired)', async () => {
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
is_active: true,
password_changed_at: null,
});
isSessionExpired.mockResolvedValue(false);
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(isSessionExpired).toHaveBeenCalledTimes(1);
});
it('does not call isSessionExpired for gallery tokens', async () => {
fakeDb.events.push({
id: 100,
slug: 'wedding',
is_active: true,
is_archived: false,
expires_at: new Date(Date.now() + 86400_000),
});
const token = signGalleryToken();
const res = await request(makeApp())
.get('/auth/session?slug=wedding')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
expect(isSessionExpired).not.toHaveBeenCalled();
});
it('falls through (treats as valid) if the helper itself throws', async () => {
// Defensive: the require() in auth.js is wrapped in try/catch so a
// missing/broken helper doesn't fail-closed during early bootstrap.
fakeDb.adminUsers.push({
id: 1,
username: 'admin',
is_active: true,
password_changed_at: null,
});
isSessionExpired.mockRejectedValue(new Error('boom'));
const token = signAdminToken({ id: 1 });
const res = await request(makeApp())
.get('/auth/session')
.set('Authorization', `Bearer ${token}`);
expect(res.status).toBe(200);
expect(res.body.valid).toBe(true);
});
});
});
@@ -1,109 +0,0 @@
/**
* Unit tests for backgroundProcessor.claimNextPhoto.
*
* Mocks the db so we don't need a live postgres/sqlite — focuses on
* the claim contract: returns null when no rows, returns row + flips
* status to 'processing' when one is available, returns null when a
* race loses the UPDATE-with-guard.
*/
jest.mock('../../src/services/photoProcessor', () => ({
processPhoto: jest.fn(),
processUploadedPhotos: jest.fn(),
queueFilesForProcessing: jest.fn(),
}));
// Build a fake knex instance whose .transaction() takes a callback we can
// drive from the test, and whose query-builder records calls.
function makeFakeDb({ pendingRow = null, updateResult = 1, clientName = 'pg' } = {}) {
const queries = [];
const builder = () => {
const recorded = { wheres: [], updates: null, ordered: false, locked: false, skipped: false };
queries.push(recorded);
const chain = {
where: jest.fn(function (...args) {
recorded.wheres.push(args);
return chain;
}),
orderBy: jest.fn(function () {
recorded.ordered = true;
return chain;
}),
forUpdate: jest.fn(function () {
recorded.locked = true;
return chain;
}),
skipLocked: jest.fn(function () {
recorded.skipped = true;
return chain;
}),
first: jest.fn(async function () {
// Only the SELECT chain returns the pending row; the UPDATE chain
// never calls .first().
return pendingRow ? { ...pendingRow } : null;
}),
update: jest.fn(async function (data) {
recorded.updates = data;
return updateResult;
}),
};
return chain;
};
const trxFn = (table) => builder(table);
trxFn.client = { config: { client: clientName } };
trxFn.transaction = async (cb) => cb(trxFn);
// Top-level db('photos') returns same builder for the janitor test path.
const db = trxFn;
return { db, queries };
}
describe('backgroundProcessor.claimNextPhoto', () => {
function loadProcessor(db) {
jest.resetModules();
jest.doMock('../../src/database/db', () => ({ db }));
return require('../../src/services/backgroundProcessor');
}
it('returns null when there are no pending photos (postgres path)', async () => {
const { db } = makeFakeDb({ pendingRow: null, clientName: 'pg' });
const bg = loadProcessor(db);
const result = await bg.claimNextPhoto();
expect(result).toBeNull();
});
it('returns the claimed row and flips status (postgres path)', async () => {
const pendingRow = { id: 42, processing_status: 'pending' };
const { db, queries } = makeFakeDb({ pendingRow, clientName: 'pg' });
const bg = loadProcessor(db);
const result = await bg.claimNextPhoto();
expect(result).toEqual(pendingRow);
// The first query is the SELECT FOR UPDATE SKIP LOCKED.
expect(queries[0].locked).toBe(true);
expect(queries[0].skipped).toBe(true);
// The second query is the status update.
expect(queries[1].updates.processing_status).toBe('processing');
expect(queries[1].updates.processing_started_at).toBeInstanceOf(Date);
});
it('returns null when the SQLite UPDATE-with-guard loses the race', async () => {
const pendingRow = { id: 7 };
const { db } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 0 });
const bg = loadProcessor(db);
const result = await bg.claimNextPhoto();
expect(result).toBeNull();
});
it('returns the row when SQLite UPDATE-with-guard wins', async () => {
const pendingRow = { id: 7 };
const { db, queries } = makeFakeDb({ pendingRow, clientName: 'better-sqlite3', updateResult: 1 });
const bg = loadProcessor(db);
const result = await bg.claimNextPhoto();
expect(result).toEqual(pendingRow);
// SQLite path: no FOR UPDATE / SKIP LOCKED.
expect(queries[0].locked).toBe(false);
expect(queries[0].skipped).toBe(false);
});
});
@@ -1,751 +0,0 @@
const { describe, it, expect, beforeEach, afterEach } = require('@jest/globals');
const mockFs = require('mock-fs');
const path = require('path');
const crypto = require('crypto');
const { EventEmitter } = require('events');
// Mock dependencies before requiring the module
jest.mock('../../src/database/db');
jest.mock('../../src/utils/logger');
jest.mock('../../src/services/emailProcessor');
jest.mock('node-cron');
jest.mock('../../src/services/backupManifest');
jest.mock('../../src/services/storage/s3Storage');
const backupService = require('../../src/services/backupService');
const { db } = require('../../src/database/db');
const logger = require('../../src/utils/logger');
const { queueEmail } = require('../../src/services/emailProcessor');
const cron = require('node-cron');
const backupManifest = require('../../src/services/backupManifest');
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
describe('Enhanced Backup Service Tests', () => {
let mockDb;
let mockS3Client;
let mockCronJob;
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Mock database
mockDb = {
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
first: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
delete: jest.fn()
};
db.mockReturnValue(mockDb);
// Mock cron job
mockCronJob = {
stop: jest.fn()
};
cron.schedule.mockReturnValue(mockCronJob);
// Mock S3 client
mockS3Client = {
testConnection: jest.fn().mockResolvedValue(true),
upload: jest.fn().mockResolvedValue({ Location: 's3://bucket/key' }),
uploadStream: jest.fn().mockResolvedValue({ Location: 's3://bucket/key' }),
download: jest.fn().mockResolvedValue(),
exists: jest.fn().mockResolvedValue(false),
delete: jest.fn().mockResolvedValue(),
list: jest.fn().mockResolvedValue({ Contents: [] })
};
S3StorageAdapter.mockImplementation(() => mockS3Client);
// Mock backup manifest
backupManifest.generateManifest = jest.fn().mockResolvedValue({
backup: { id: 'test-backup-123' },
version: '2.0'
});
backupManifest.saveManifest = jest.fn().mockResolvedValue('/path/to/manifest.json');
backupManifest.loadManifest = jest.fn().mockResolvedValue({});
backupManifest.validateManifest = jest.fn();
backupManifest.generateSummaryReport = jest.fn().mockReturnValue('Summary report');
// Mock logger
logger.info = jest.fn();
logger.error = jest.fn();
logger.warn = jest.fn();
logger.debug = jest.fn();
});
afterEach(() => {
mockFs.restore();
});
describe('getBackupConfig', () => {
it('should retrieve and parse backup configuration from database', async () => {
const mockSettings = [
{ setting_key: 'backup_enabled', setting_value: 'true' },
{ setting_key: 'backup_destination_type', setting_value: '"s3"' },
{ setting_key: 'backup_s3_bucket', setting_value: '"test-bucket"' },
{ setting_key: 'backup_retention_days', setting_value: '30' }
];
mockDb.select.mockResolvedValue(mockSettings);
const config = await backupService.getBackupConfig();
expect(config).toEqual({
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_retention_days: 30
});
expect(db).toHaveBeenCalledWith('app_settings');
expect(mockDb.where).toHaveBeenCalledWith('setting_type', 'backup');
});
it('should handle JSON parse errors gracefully', async () => {
const mockSettings = [
{ setting_key: 'backup_enabled', setting_value: 'invalid-json' }
];
mockDb.select.mockResolvedValue(mockSettings);
const config = await backupService.getBackupConfig();
expect(config).toEqual({
backup_enabled: 'invalid-json'
});
});
it('should return null on database error', async () => {
mockDb.select.mockRejectedValue(new Error('Database error'));
const config = await backupService.getBackupConfig();
expect(config).toBeNull();
expect(logger.error).toHaveBeenCalled();
});
});
describe('S3 Backup Functionality', () => {
beforeEach(() => {
// Mock file system
mockFs({
'/storage/events/active/event1': {
'photo1.jpg': Buffer.from('photo1 content'),
'photo2.jpg': Buffer.from('photo2 content')
},
'/storage/events/archived/event2.zip': Buffer.from('archived content'),
'/storage/thumbnails': {
'thumb1.jpg': Buffer.from('thumb1 content')
},
'/storage/uploads': {
'logo.png': Buffer.from('logo content')
}
});
process.env.STORAGE_PATH = '/storage';
});
it('should perform S3 backup with correct configuration', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_region: 'us-east-1',
backup_s3_endpoint: 'https://s3.amazonaws.com',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret',
backup_include_archived: true,
backup_max_file_size_mb: 100
};
mockDb.select.mockResolvedValue([]);
mockDb.where.mockReturnThis();
mockDb.first.mockResolvedValue(null);
mockDb.insert.mockResolvedValue([1]);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
type: 'sqlite',
backupFile: null,
hasChanged: true
});
await backupService.runBackup();
expect(S3StorageAdapter).toHaveBeenCalledWith({
bucket: 'test-bucket',
region: 'us-east-1',
endpoint: 'https://s3.amazonaws.com',
accessKeyId: 'test-key',
secretAccessKey: 'test-secret',
forcePathStyle: false,
sslEnabled: true,
maxRetries: 3,
retryDelay: 1000
});
expect(mockS3Client.testConnection).toHaveBeenCalled();
expect(mockS3Client.upload).toHaveBeenCalled();
});
it('should handle S3 upload failures gracefully', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockS3Client.testConnection.mockRejectedValue(new Error('Connection failed'));
await backupService.runBackup();
expect(logger.error).toHaveBeenCalledWith('S3 backup failed:', expect.any(Error));
expect(mockDb.update).toHaveBeenCalledWith(expect.objectContaining({
status: 'failed',
error_message: expect.stringContaining('Connection failed')
}));
});
it('should skip unchanged files in incremental backup', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret',
backup_incremental: true
};
// Mock existing file state
mockDb.first.mockImplementation((query) => {
if (query === undefined) {
return Promise.resolve({
file_path: 'events/active/event1/photo1.jpg',
checksum: crypto.createHash('sha256').update('photo1 content').digest('hex')
});
}
return Promise.resolve(null);
});
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
await backupService.runBackup();
// Should skip unchanged file
const uploadCalls = mockS3Client.upload.mock.calls;
const photo1Uploaded = uploadCalls.some(call =>
call[1].includes('photo1.jpg')
);
expect(photo1Uploaded).toBe(false);
});
it('should include database backup when configured', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret',
backup_include_database: true
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
type: 'sqlite',
backupFile: '/backup/db-backup.sql',
size: 1024000,
checksum: 'abc123',
hasChanged: false
});
// Mock database backup file
mockFs({
'/storage/events/active': {},
'/backup/db-backup.sql': Buffer.from('database backup content')
});
await backupService.runBackup();
// Verify database backup was uploaded
const uploadCalls = mockS3Client.upload.mock.calls;
const dbBackupUploaded = uploadCalls.some(call =>
call[1].includes('database/db-backup.sql')
);
expect(dbBackupUploaded).toBe(true);
});
it('should validate required S3 configuration', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket'
// Missing access key and secret key
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
await backupService.runBackup();
expect(logger.error).toHaveBeenCalledWith(
'S3 backup failed:',
expect.objectContaining({
message: expect.stringContaining('S3 backup configuration incomplete')
})
);
});
});
describe('Manifest Generation', () => {
it('should generate and save manifest for successful backup', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 'local',
backup_destination_path: '/backup',
backup_manifest_format: 'json'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({
'/storage/events/active/event1': {
'photo1.jpg': Buffer.from('photo1 content')
},
'/backup': {}
});
await backupService.runBackup();
expect(backupManifest.generateManifest).toHaveBeenCalledWith(
expect.objectContaining({
backupType: 'full',
backupPath: '/backup',
format: 'json'
})
);
expect(backupManifest.saveManifest).toHaveBeenCalled();
});
it('should generate incremental manifest when parent exists', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 'local',
backup_destination_path: '/backup'
};
const lastBackup = {
id: 1,
manifest_path: '/backup/manifests/previous.json',
manifest_id: 'previous-backup-123'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([2]);
mockDb.first.mockImplementation(() => Promise.resolve(lastBackup));
mockDb.orderBy.mockReturnThis();
mockDb.where.mockReturnThis();
mockDb.whereNot = jest.fn().mockReturnThis();
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({
'/storage/events/active': {},
'/backup': {}
});
await backupService.runBackup();
expect(backupManifest.loadManifest).toHaveBeenCalledWith('/backup/manifests/previous.json');
expect(backupManifest.generateIncrementalManifest).toHaveBeenCalled();
});
it('should upload manifest to S3 for S3 backups', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret',
backup_manifest_format: 'yaml'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
const manifest = {
backup: { id: 'backup-123' },
version: '2.0'
};
backupManifest.generateManifest.mockResolvedValue(manifest);
mockFs({
'/storage/events/active': {},
'/storage/temp': {}
});
await backupService.runBackup();
// Verify manifest was uploaded to S3
const uploadCalls = mockS3Client.upload.mock.calls;
const manifestUploaded = uploadCalls.some(call =>
call[1].includes('manifests/backup-manifest-backup-123.yaml')
);
expect(manifestUploaded).toBe(true);
});
});
describe('Backward Compatibility', () => {
it('should support local backup destination', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 'local',
backup_destination_path: '/backup/local'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({
'/storage/events/active/event1': {
'photo1.jpg': Buffer.from('photo1 content')
},
'/backup/local': {}
});
await backupService.runBackup();
// Verify files were copied to local destination
const fs = require('fs');
const destPath = '/backup/local/events/active/event1/photo1.jpg';
expect(fs.existsSync(destPath)).toBe(true);
});
it('should support rsync backup destination', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 'rsync',
backup_rsync_host: 'backup.example.com',
backup_rsync_user: 'backup',
backup_rsync_path: '/remote/backup'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
// Mock exec for rsync
const { exec } = require('child_process');
const mockExec = jest.fn((cmd, callback) => {
callback(null, { stdout: 'Number of files transferred: 1\nTotal file size: 1024 bytes' });
});
exec.mockImplementation(mockExec);
mockFs({
'/storage/events/active': {}
});
await backupService.runBackup();
expect(mockExec).toHaveBeenCalledWith(
expect.stringContaining('rsync'),
expect.any(Function)
);
});
});
describe('Error Handling and Recovery', () => {
it('should handle file read errors gracefully', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
// Mock file that throws error on read
const fs = require('fs');
const originalCreateReadStream = fs.createReadStream;
fs.createReadStream = jest.fn((path) => {
if (path.includes('error.jpg')) {
const stream = new EventEmitter();
process.nextTick(() => stream.emit('error', new Error('File read error')));
return stream;
}
return originalCreateReadStream(path);
});
mockFs({
'/storage/events/active': {
'error.jpg': Buffer.from('content'),
'good.jpg': Buffer.from('content')
}
});
await backupService.runBackup();
// Should continue with other files despite error
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to backup file'),
expect.any(Error)
);
fs.createReadStream = originalCreateReadStream;
});
it('should send failure email on backup error', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_email_on_failure: true
};
const admins = [
{ email: 'admin1@example.com', is_active: true },
{ email: 'admin2@example.com', is_active: true }
];
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.where.mockReturnThis();
jest.spyOn(backupService, 'getBackupConfig')
.mockResolvedValueOnce(config)
.mockResolvedValueOnce(config);
// Force an error
jest.spyOn(backupService, 'getFilesToBackup').mockRejectedValue(new Error('Storage error'));
// Mock admin users query
db.mockImplementation((table) => {
if (table === 'admin_users') {
return {
where: jest.fn().mockResolvedValue(admins)
};
}
return mockDb;
});
await backupService.runBackup();
expect(queueEmail).toHaveBeenCalledTimes(2);
expect(queueEmail).toHaveBeenCalledWith(
null,
'admin1@example.com',
'backup_failed',
expect.objectContaining({
error_message: 'Storage error'
})
);
});
it('should handle concurrent backup attempts', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 'local',
backup_destination_path: '/backup'
};
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({
'/storage/events/active': {},
'/backup': {}
});
// Start two backups concurrently
const backup1 = backupService.runBackup();
const backup2 = backupService.runBackup();
await Promise.all([backup1, backup2]);
// Second backup should be skipped
expect(logger.warn).toHaveBeenCalledWith('Backup already running, skipping');
});
});
describe('Service Lifecycle', () => {
it('should start backup service with cron schedule', async () => {
const config = {
backup_enabled: true,
backup_schedule: '0 3 * * *' // 3 AM daily
};
mockDb.select.mockResolvedValue(
Object.entries(config).map(([key, value]) => ({
setting_key: key,
setting_value: value.toString()
}))
);
await backupService.startBackupService();
expect(cron.schedule).toHaveBeenCalledWith('0 3 * * *', expect.any(Function));
expect(logger.info).toHaveBeenCalledWith('Backup service started with schedule: 0 3 * * *');
});
it('should stop existing job when restarting service', async () => {
const config = {
backup_enabled: true,
backup_schedule: '0 2 * * *'
};
mockDb.select.mockResolvedValue(
Object.entries(config).map(([key, value]) => ({
setting_key: key,
setting_value: value.toString()
}))
);
// Start service twice
await backupService.startBackupService();
await backupService.startBackupService();
expect(mockCronJob.stop).toHaveBeenCalled();
});
it('should not start service when backup is disabled', async () => {
const config = {
backup_enabled: false
};
mockDb.select.mockResolvedValue([
{ setting_key: 'backup_enabled', setting_value: 'false' }
]);
await backupService.startBackupService();
expect(cron.schedule).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith('Backup service is disabled');
});
});
describe('Backup Status and History', () => {
it('should return backup status with recent runs', async () => {
const recentRuns = [
{
id: 1,
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
files_backed_up: 100,
total_size_bytes: 1024000,
manifest_path: '/backup/manifest.json'
}
];
mockDb.limit.mockResolvedValue(recentRuns);
backupManifest.validateManifest.mockImplementation(() => true);
const status = await backupService.getBackupStatus();
expect(status).toEqual({
isRunning: false,
isHealthy: true,
lastRun: expect.objectContaining({
...recentRuns[0],
manifestValid: true
}),
recentRuns: recentRuns,
nextScheduledRun: expect.any(String)
});
});
it('should clean up old backup runs', async () => {
mockDb.delete.mockResolvedValue(5);
await backupService.cleanupOldBackupRuns(30);
expect(mockDb.where).toHaveBeenCalledWith('started_at', '<', expect.any(Date));
expect(mockDb.delete).toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith('Cleaned up 5 old backup runs');
});
});
describe('getBackupManifest', () => {
it('should retrieve manifest from local filesystem', async () => {
const backupRun = {
id: 1,
manifest_path: '/backup/manifests/backup-123.json'
};
mockDb.first.mockResolvedValue(backupRun);
const manifest = { backup: { id: 'backup-123' } };
backupManifest.loadManifest.mockResolvedValue(manifest);
backupManifest.generateSummaryReport.mockReturnValue('Summary');
const result = await backupService.getBackupManifest(1);
expect(result).toEqual({
manifest: manifest,
summary: 'Summary'
});
});
it('should retrieve manifest from S3', async () => {
const backupRun = {
id: 1,
manifest_path: 's3://test-bucket/backups/manifests/backup-123.json'
};
mockDb.first.mockResolvedValue(backupRun);
mockDb.select.mockResolvedValue([
{ setting_key: 'backup_s3_access_key', setting_value: '"test-key"' },
{ setting_key: 'backup_s3_secret_key', setting_value: '"test-secret"' }
]);
const manifest = { backup: { id: 'backup-123' } };
backupManifest.loadManifest.mockResolvedValue(manifest);
await backupService.getBackupManifest(1);
expect(S3StorageAdapter).toHaveBeenCalled();
expect(mockS3Client.download).toHaveBeenCalledWith(
'backups/manifests/backup-123.json',
expect.any(String)
);
});
});
});
@@ -1,75 +0,0 @@
/**
* Unit tests for emailProcessor.htmlToText.
*
* Regression: when a template ships without a body_text, sendTemplateEmail
* used `htmlBody.replace(/<[^>]*>/g, '')` to derive the plain-text fallback.
* That regex strips angle-bracket tags but leaves the *contents* of <style>
* and <script> blocks intact — so any HTML wrapped by wrapEmailHtml() (which
* embeds a 100+ line <style> block) produced a "plain-text" email starting
* with `body { margin: 0; padding: 0; … }`. htmlToText fixes that.
*/
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
const { htmlToText } = require('../../src/services/emailProcessor');
describe('htmlToText', () => {
it('returns empty string for empty input', () => {
expect(htmlToText('')).toBe('');
expect(htmlToText(null)).toBe('');
expect(htmlToText(undefined)).toBe('');
});
it('strips <style> blocks and their contents', () => {
const html = '<html><head><style>body { margin: 0; color: red; }</style></head><body>Hello</body></html>';
const out = htmlToText(html);
expect(out).toBe('Hello');
expect(out).not.toMatch(/margin/);
expect(out).not.toMatch(/color/);
});
it('strips <script> blocks and their contents', () => {
const html = '<body><script>alert("x")</script>Hi</body>';
expect(htmlToText(html)).toBe('Hi');
});
it('converts <br> tags to newlines', () => {
expect(htmlToText('a<br>b<br />c<BR/>d')).toBe('a\nb\nc\nd');
});
it('keeps a paragraph break between adjacent <p> tags', () => {
expect(htmlToText('<p>one</p><p>two</p>')).toBe('one\n\ntwo');
});
it('decodes the common HTML entities', () => {
expect(htmlToText('Tom &amp; Jerry &lt;3 &quot;hi&quot;'))
.toBe('Tom & Jerry <3 "hi"');
});
it('handles a fully-wrapped email body without leaking CSS rules', () => {
// Shape mirrors what wrapEmailHtml() produces: a <style> block with many
// CSS rules followed by the actual content.
const wrapped = `
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; padding: 0; font-family: sans-serif; background-color: #f5f5f5; }
.email-container { max-width: 600px; }
.button { background-color: #5C8762; color: white !important; }
</style>
</head>
<body>
<h2>Galerie erfolgreich erstellt</h2>
<p>Liebe(r) Natalie,</p>
</body>
</html>`;
const out = htmlToText(wrapped);
expect(out).toContain('Galerie erfolgreich erstellt');
expect(out).toContain('Liebe(r) Natalie');
expect(out).not.toMatch(/margin/);
expect(out).not.toMatch(/font-family/);
expect(out).not.toMatch(/background-color/);
expect(out).not.toMatch(/\.button/);
});
});
@@ -1,138 +0,0 @@
/**
* Unit tests for emailProcessor.safeTemplateReplace.
*
* Covers the two regressions that hit picpeak.nothaft.cloud on the
* 3.32.x betas:
* - {{#if VAR}}…{{/if}} blocks rendered as literal text in the email
* because the renderer only handled {{var}} substitution and the
* shipped templates use Handlebars-style conditionals.
* - {{var}} substitution inside a kept conditional block.
*
* The publish-from-draft password localisation lives inside the wider
* processTemplate() pipeline (DB-backed), so it isn't covered here — the
* sentinel string '(set at creation)' is asserted only at the i18n-map
* level by integration in adminEvents.js.
*/
jest.mock('../../src/database/db', () => ({ db: jest.fn() }));
const { safeTemplateReplace } = require('../../src/services/emailProcessor');
describe('safeTemplateReplace', () => {
describe('flat variable substitution', () => {
it('replaces {{var}} with the variable value', () => {
expect(safeTemplateReplace('Hello {{name}}!', { name: 'Paul' }))
.toBe('Hello Paul!');
});
it('leaves unknown variables untouched', () => {
expect(safeTemplateReplace('Hello {{name}}!', {}))
.toBe('Hello {{name}}!');
});
it('coerces non-string values to string', () => {
expect(safeTemplateReplace('Count: {{n}}', { n: 42 }))
.toBe('Count: 42');
});
it('handles empty templates and missing variables map', () => {
expect(safeTemplateReplace('', { x: 1 })).toBe('');
expect(safeTemplateReplace('plain text', undefined)).toBe('plain text');
expect(safeTemplateReplace(null, {})).toBe(null);
});
});
describe('{{#if VAR}}…{{/if}} blocks', () => {
it('strips the block when the variable is missing', () => {
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
expect(safeTemplateReplace(tpl, {})).toBe('before after');
});
it('strips the block when the variable is an empty string', () => {
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
expect(safeTemplateReplace(tpl, { welcome: '' })).toBe('before after');
});
it('strips the block when the variable is null', () => {
const tpl = '{{#if x}}kept{{/if}}';
expect(safeTemplateReplace(tpl, { x: null })).toBe('');
});
it('keeps the block and substitutes inside it when truthy', () => {
const tpl = 'before {{#if welcome}}HELLO {{welcome}}{{/if}} after';
expect(safeTemplateReplace(tpl, { welcome: 'world' }))
.toBe('before HELLO world after');
});
it('handles multi-line conditional blocks', () => {
const tpl = [
'Liebe(r) {{host_name}},',
'',
'{{#if welcome_message}}',
'Persönliche Nachricht:',
'{{welcome_message}}',
'{{/if}}',
'Galerie-Details:',
].join('\n');
const withMsg = safeTemplateReplace(tpl, {
host_name: 'Natalie',
welcome_message: 'Schön, dass ihr da seid!',
});
expect(withMsg).toContain('Persönliche Nachricht:');
expect(withMsg).toContain('Schön, dass ihr da seid!');
expect(withMsg).not.toContain('{{#if');
expect(withMsg).not.toContain('{{/if');
const withoutMsg = safeTemplateReplace(tpl, {
host_name: 'Natalie',
welcome_message: '',
});
expect(withoutMsg).not.toContain('Persönliche Nachricht');
expect(withoutMsg).not.toContain('{{#if');
expect(withoutMsg).not.toContain('{{/if');
expect(withoutMsg).toContain('Liebe(r) Natalie,');
expect(withoutMsg).toContain('Galerie-Details:');
});
it('handles multiple sibling conditionals independently', () => {
const tpl = '{{#if a}}A{{/if}}|{{#if b}}B{{/if}}|{{#if c}}C{{/if}}';
expect(safeTemplateReplace(tpl, { a: 1, c: 'yes' })).toBe('A||C');
});
it('treats numeric 0 as falsy', () => {
expect(safeTemplateReplace('{{#if n}}has-n{{/if}}', { n: 0 })).toBe('');
});
});
describe('HTML escaping (escapeHtml: true)', () => {
it('does not escape by default', () => {
const tpl = 'Welcome to {{event_name}}';
expect(safeTemplateReplace(tpl, { event_name: 'Test <script>' }))
.toBe('Welcome to Test <script>');
});
it('escapes admin-supplied values when opted in', () => {
const tpl = 'Welcome to {{event_name}}';
expect(safeTemplateReplace(tpl, { event_name: 'Test <script>alert(1)</script>' }, { escapeHtml: true }))
.toBe('Welcome to Test &lt;script&gt;alert(1)&lt;/script&gt;');
});
it('escapes both the < > and & characters and quotes', () => {
expect(safeTemplateReplace('{{x}}', { x: '<a href="evil">A & B\'s</a>' }, { escapeHtml: true }))
.toBe('&lt;a href=&quot;evil&quot;&gt;A &amp; B&#39;s&lt;/a&gt;');
});
it('passes welcome_message through unescaped (already HTML from formatWelcomeMessage)', () => {
const tpl = '<p>{{welcome_message}}</p>';
expect(safeTemplateReplace(tpl, { welcome_message: 'Hi<br />there' }, { escapeHtml: true }))
.toBe('<p>Hi<br />there</p>');
});
it('passes server-generated URLs through unescaped', () => {
const tpl = '<a href="{{gallery_link}}">link</a>';
expect(safeTemplateReplace(tpl, { gallery_link: 'https://example.com/g/abc?token=xyz&u=1' }, { escapeHtml: true }))
.toBe('<a href="https://example.com/g/abc?token=xyz&u=1">link</a>');
});
});
});
@@ -1,320 +0,0 @@
const fs = require('fs');
const fsPromises = fs.promises;
const os = require('os');
const path = require('path');
// Silence the logger so test output stays clean. Capture calls so the
// "warning logged" assertions can still verify behaviour.
jest.mock('../../src/utils/logger', () => ({
warn: jest.fn(),
info: jest.fn(),
error: jest.fn(),
debug: jest.fn()
}));
const logger = require('../../src/utils/logger');
// Required ONCE at module top so the jest.mock factory above applies to
// the logger reference that fontsService captures. A previous version
// re-required it inside beforeEach() with jest.resetModules() — that
// silently bypassed the mock (logger calls went to the real logger),
// so the "warning logged" assertions would resolve as 0 calls and
// silently pass-as-noop. Module-level state in fontsService is just
// the cache, which clearFontsCache() resets between tests.
const fontsService = require('../../src/services/fontsService');
// Probe at load time: is the host filesystem case-sensitive?
// macOS APFS and Windows NTFS treat "Inter" and "INTER" as the same
// directory entry, which means the "two folders, same lowercase key"
// dedup test below can't be set up via real folders on those platforms —
// the second mkdir is a no-op. Skip that one test conditionally.
const FS_IS_CASE_SENSITIVE = (() => {
const probeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-fs-probe-'));
fs.writeFileSync(path.join(probeDir, 'casetest'), '');
let sensitive = true;
try {
fs.accessSync(path.join(probeDir, 'CASETEST'));
sensitive = false;
} catch { /* file not found → case-sensitive FS */ }
fs.rmSync(probeDir, { recursive: true, force: true });
return sensitive;
})();
const testCaseSensitiveFS = FS_IS_CASE_SENSITIVE ? test : test.skip;
let bundledRoot;
let userRoot;
/**
* Create a font family folder with the given weights (and optional meta.json).
* @param {string} root absolute path to the bundled or user root
* @param {string} folderName e.g. "Inter" or "Playfair-Display"
* @param {Array<number>|Array<string>} weights numeric weights (creates `<w>.woff2`)
* or filenames to create directly
* @param {Object|null} meta optional meta.json contents (object) or null
*/
async function makeFamily(root, folderName, weights, meta = null) {
const dir = path.join(root, folderName);
await fsPromises.mkdir(dir, { recursive: true });
for (const w of weights) {
const fname = typeof w === 'number' ? `${w}.woff2` : w;
await fsPromises.writeFile(path.join(dir, fname), Buffer.from([]));
}
if (meta !== null) {
await fsPromises.writeFile(
path.join(dir, 'meta.json'),
typeof meta === 'string' ? meta : JSON.stringify(meta)
);
}
return dir;
}
beforeEach(async () => {
jest.clearAllMocks();
bundledRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-bundled-'));
userRoot = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-user-'));
process.env.PICPEAK_BUNDLED_FONTS_ROOT = bundledRoot;
// The user root resolves under STORAGE_PATH/fonts, so STORAGE_PATH must
// point at the parent of userRoot — we name the leaf "fonts" ourselves.
const storageParent = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-fonts-storage-'));
await fsPromises.rename(userRoot, path.join(storageParent, 'fonts'));
userRoot = path.join(storageParent, 'fonts');
process.env.STORAGE_PATH = storageParent;
// Reset the module-level cache so each test sees a fresh scan.
// (Both getBundledFontsRoot and getUserFontsRoot read process.env at
// call-time, so the env vars set above are picked up without needing
// to re-require the module — see fontsService.js getBundledFontsRoot /
// getUserFontsRoot.)
fontsService.clearFontsCache();
});
afterEach(async () => {
fontsService.clearFontsCache();
await fsPromises.rm(bundledRoot, { recursive: true, force: true }).catch(() => {});
// userRoot's parent is the actual mkdtemp; remove it.
await fsPromises.rm(path.dirname(userRoot), { recursive: true, force: true }).catch(() => {});
delete process.env.PICPEAK_BUNDLED_FONTS_ROOT;
delete process.env.STORAGE_PATH;
});
describe('fontsService.listFonts', () => {
describe('roots', () => {
test('empty bundled root + missing user root → []', async () => {
// delete user root so it triggers ENOENT
await fsPromises.rm(path.dirname(userRoot), { recursive: true, force: true });
const fonts = await fontsService.listFonts();
expect(fonts).toEqual([]);
});
test('missing bundled root (ENOENT) → [], does not throw', async () => {
await fsPromises.rm(bundledRoot, { recursive: true, force: true });
const fonts = await fontsService.listFonts();
expect(fonts).toEqual([]);
});
test('non-directory entries at the root are skipped', async () => {
await fsPromises.writeFile(path.join(bundledRoot, 'README.md'), 'hi');
await makeFamily(bundledRoot, 'Inter', [400, 700]);
const fonts = await fontsService.listFonts();
expect(fonts.map((f) => f.family)).toEqual(['Inter']);
});
test('hidden folders are skipped', async () => {
await makeFamily(bundledRoot, '.git', [400]);
await makeFamily(bundledRoot, '.DS_Store', [400]);
await makeFamily(bundledRoot, 'Inter', [400]);
const fonts = await fontsService.listFonts();
expect(fonts.map((f) => f.family)).toEqual(['Inter']);
});
});
describe('weight parsing', () => {
test('three weight files → sorted ascending', async () => {
await makeFamily(bundledRoot, 'Inter', [700, 400, 600]);
const [inter] = await fontsService.listFonts();
expect(inter.weights).toEqual([400, 600, 700]);
});
test('non-numeric filenames are ignored', async () => {
await makeFamily(bundledRoot, 'Inter', ['bold.woff2', 'regular.woff2', '400.woff2', '700.woff2']);
const [inter] = await fontsService.listFonts();
expect(inter.weights).toEqual([400, 700]);
});
test('non-.woff2 files are ignored', async () => {
await makeFamily(bundledRoot, 'Inter', ['400.ttf', '400.woff', '400.woff2', '700.otf']);
const [inter] = await fontsService.listFonts();
expect(inter.weights).toEqual([400]);
});
test('weight values out of range (sub-1 / over-1000) are ignored', async () => {
await makeFamily(bundledRoot, 'Inter', [0, 400, 1001, 700]);
const [inter] = await fontsService.listFonts();
expect(inter.weights).toEqual([400, 700]);
});
test('family folder with no usable .woff2 files is silently skipped', async () => {
await makeFamily(bundledRoot, 'NoWeights', ['readme.txt', 'bold.ttf']);
await makeFamily(bundledRoot, 'Inter', [400]);
const fonts = await fontsService.listFonts();
expect(fonts.map((f) => f.family)).toEqual(['Inter']);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Skipping NoWeights')
);
});
});
describe('folder name → display family', () => {
test('hyphens become spaces', async () => {
await makeFamily(bundledRoot, 'Playfair-Display', [400]);
const [pd] = await fontsService.listFonts();
expect(pd.family).toBe('Playfair Display');
});
test('case is preserved', async () => {
await makeFamily(bundledRoot, 'IBM-Plex-Sans', [400]);
const [ibm] = await fontsService.listFonts();
expect(ibm.family).toBe('IBM Plex Sans');
});
});
describe('user-overrides-bundled', () => {
test('user folder of the same family wins; weights come from user', async () => {
await makeFamily(bundledRoot, 'Inter', [400, 600, 700]);
await makeFamily(userRoot, 'Inter', [400, 900]); // different weights
const [inter] = await fontsService.listFonts();
expect(inter.weights).toEqual([400, 900]);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('overrides bundled default')
);
});
test('user-only family is included', async () => {
await makeFamily(userRoot, 'Lobster', [400]);
const fonts = await fontsService.listFonts();
expect(fonts.map((f) => f.family)).toEqual(['Lobster']);
});
testCaseSensitiveFS('case-insensitive duplicate within the same root → second skipped, warning', async () => {
// Two folder names whose lowercase keys collide. On a case-sensitive
// FS (Linux ext4) we can create both `Inter/` and `INTER/`; on a
// case-insensitive FS (macOS APFS, Windows NTFS) the second mkdir
// resolves to the same directory as the first and the dedup branch
// is unreachable from this test setup — see testCaseSensitiveFS above.
await makeFamily(bundledRoot, 'Inter', [400]);
await makeFamily(bundledRoot, 'INTER', [700]);
const fonts = await fontsService.listFonts();
expect(fonts).toHaveLength(1);
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('Duplicate family')
);
});
});
describe('meta.json — generic fallback', () => {
test('valid generic="serif"', async () => {
await makeFamily(bundledRoot, 'Playfair-Display', [400], { generic: 'serif' });
const [pd] = await fontsService.listFonts();
expect(pd.generic).toBe('serif');
});
test('valid generic="cursive"', async () => {
await makeFamily(bundledRoot, 'Comic-Neue', [400], { generic: 'cursive' });
const [cn] = await fontsService.listFonts();
expect(cn.generic).toBe('cursive');
});
test('valid generic="monospace"', async () => {
await makeFamily(bundledRoot, 'Fira-Mono', [400], { generic: 'monospace' });
const [fm] = await fontsService.listFonts();
expect(fm.generic).toBe('monospace');
});
test('missing meta.json → defaults to sans-serif (no warning)', async () => {
await makeFamily(bundledRoot, 'Inter', [400]);
const [inter] = await fontsService.listFonts();
expect(inter.generic).toBe('sans-serif');
// No warning for the missing-file case (it's the normal path).
const noisy = (logger.warn.mock.calls || []).filter((c) =>
String(c[0]).includes('meta.json')
);
expect(noisy).toEqual([]);
});
test('invalid generic value → defaults to sans-serif, warning logged', async () => {
await makeFamily(bundledRoot, 'Inter', [400], { generic: 'bogus' });
const [inter] = await fontsService.listFonts();
expect(inter.generic).toBe('sans-serif');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('invalid generic "bogus"')
);
});
test('malformed JSON → defaults to sans-serif, warning logged', async () => {
await makeFamily(bundledRoot, 'Inter', [400], '{ this is not json');
const [inter] = await fontsService.listFonts();
expect(inter.generic).toBe('sans-serif');
expect(logger.warn).toHaveBeenCalledWith(
expect.stringContaining('not valid JSON')
);
});
});
describe('result shape', () => {
test('every family is { family, weights, generic }', async () => {
await makeFamily(bundledRoot, 'Inter', [400, 700]);
await makeFamily(bundledRoot, 'Playfair-Display', [400], { generic: 'serif' });
const fonts = await fontsService.listFonts();
for (const f of fonts) {
expect(f).toEqual({
family: expect.any(String),
weights: expect.any(Array),
generic: expect.stringMatching(/^(sans-serif|serif|cursive|monospace)$/)
});
expect(f.weights.length).toBeGreaterThan(0);
}
});
test('output sorted alphabetically by family', async () => {
await makeFamily(bundledRoot, 'Zilla-Slab', [400]);
await makeFamily(bundledRoot, 'Alpha-Sans', [400]);
await makeFamily(bundledRoot, 'Mid-Pack', [400]);
const fonts = await fontsService.listFonts();
expect(fonts.map((f) => f.family)).toEqual([
'Alpha Sans',
'Mid Pack',
'Zilla Slab'
]);
});
});
describe('cache', () => {
test('cache hit: second call within TTL does not re-readdir', async () => {
await makeFamily(bundledRoot, 'Inter', [400]);
const spy = jest.spyOn(fsPromises, 'readdir');
await fontsService.listFonts();
const callsAfterFirst = spy.mock.calls.length;
await fontsService.listFonts();
expect(spy.mock.calls.length).toBe(callsAfterFirst);
spy.mockRestore();
});
test('clearFontsCache forces a fresh scan on the next call', async () => {
await makeFamily(bundledRoot, 'Inter', [400]);
await fontsService.listFonts();
// Add a new family AFTER the cache was populated.
await makeFamily(bundledRoot, 'Roboto', [400]);
// Without clearing, listFonts returns the stale cache.
const stale = await fontsService.listFonts();
expect(stale.map((f) => f.family)).toEqual(['Inter']);
// After clear, the new family appears.
fontsService.clearFontsCache();
const fresh = await fontsService.listFonts();
expect(fresh.map((f) => f.family)).toEqual(['Inter', 'Roboto']);
});
});
});
@@ -1,216 +0,0 @@
/**
* Unit tests for photoProcessor.processPhoto — the worker-mode entry
* point that runs after a row has been claimed by the background
* processor. Mocks every external dependency and validates the
* happy-path DB updates and side-effect ordering.
*
* jest.mock factories are evaluated before any local variables exist,
* so collaborators are kept inside the mock factories themselves and
* the test reaches into them via require() once they're set up.
*/
const path = require('path');
jest.mock('../../src/database/db', () => {
const recorded = { whereCalls: [], updateCalls: [] };
let pendingWhere = null;
const photosState = { row: null };
const eventsState = { row: null };
function makePhotoQuery() {
return {
where(args) {
pendingWhere = args;
recorded.whereCalls.push(args);
return this;
},
async first() {
return photosState.row;
},
async update(data) {
recorded.updateCalls.push({ where: pendingWhere, data });
return 1;
},
};
}
function makeEventsQuery() {
return {
where() {
return this;
},
async first() {
return eventsState.row;
},
};
}
function dbFn(table) {
if (table === 'photos') return makePhotoQuery();
if (table === 'events') return makeEventsQuery();
throw new Error(`Unexpected table: ${table}`);
}
dbFn.client = { config: { client: 'pg' } };
return {
db: dbFn,
__setPhoto: (row) => { photosState.row = row; },
__setEvent: (row) => { eventsState.row = row; },
__reset: () => {
recorded.whereCalls = [];
recorded.updateCalls = [];
photosState.row = null;
eventsState.row = null;
},
__recorded: () => recorded,
};
});
jest.mock('../../src/services/imageProcessor', () => {
const mockGenerateThumbnail = jest.fn();
const mockExtractCaptureDate = jest.fn();
return {
generateThumbnail: mockGenerateThumbnail,
extractCaptureDate: mockExtractCaptureDate,
withLocalCopy: jest.fn(async (key, fn) =>
fn(`/tmp/local-copy-${require('path').basename(key)}`)
),
};
});
jest.mock('../../src/services/videoProcessor', () => ({
processUploadedVideo: jest.fn(),
isVideoMimeType: (mime) => typeof mime === 'string' && mime.startsWith('video/'),
}));
jest.mock('../../src/services/storage', () => ({ getStorage: jest.fn() }));
jest.mock('../../src/services/photoResolver', () => ({
resolvePhotoStorageKey: jest.fn(
(event, photo) => `events/active/${event.slug}/${photo.filename}`
),
}));
jest.mock('../../src/utils/filenameSanitizer', () => ({
generatePhotoFilename: jest.fn(() => 'whatever.jpg'),
}));
jest.mock('../../src/services/watermarkGeneratorService', () => ({
generateForPhoto: jest.fn(() => Promise.resolve()),
}));
jest.mock('../../src/services/webhookService', () => ({
fire: jest.fn(() => Promise.resolve()),
}));
jest.mock('../../src/utils/logger', () => ({
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
}));
// Stub sharp so we don't actually read any image off disk.
jest.mock('sharp', () => {
const mock = jest.fn(() => ({
metadata: jest.fn(async () => ({ width: 1920, height: 1080 })),
}));
return mock;
});
const dbModule = require('../../src/database/db');
const imageProcessor = require('../../src/services/imageProcessor');
const videoProcessor = require('../../src/services/videoProcessor');
const watermarkService = require('../../src/services/watermarkGeneratorService');
const webhookService = require('../../src/services/webhookService');
beforeEach(() => {
dbModule.__reset();
jest.clearAllMocks();
});
describe('photoProcessor.processPhoto', () => {
it('marks an image complete with thumbnail and dimensions', async () => {
dbModule.__setPhoto({
id: 101,
event_id: 5,
filename: 'wedding-001.jpg',
original_filename: 'IMG_0001.jpg',
mime_type: 'image/jpeg',
media_type: 'image',
size_bytes: 12345,
captured_at: null,
processing_status: 'processing',
});
dbModule.__setEvent({ id: 5, slug: 'wedding', event_name: 'Wedding' });
imageProcessor.extractCaptureDate.mockResolvedValueOnce('2026-04-25T12:00:00Z');
imageProcessor.generateThumbnail.mockResolvedValueOnce('thumbnails/thumb_wedding-001.jpg');
const { processPhoto } = require('../../src/services/photoProcessor');
await processPhoto(101);
const finalUpdate = dbModule.__recorded().updateCalls.pop();
expect(finalUpdate.data.processing_status).toBe('complete');
expect(finalUpdate.data.processing_error).toBeNull();
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-001.jpg');
expect(finalUpdate.data.width).toBe(1920);
expect(finalUpdate.data.height).toBe(1080);
expect(finalUpdate.data.captured_at).toBe('2026-04-25T12:00:00Z');
expect(watermarkService.generateForPhoto).toHaveBeenCalledWith(101);
expect(webhookService.fire).toHaveBeenCalledWith(
'photo.uploaded',
expect.objectContaining({
event: expect.objectContaining({ slug: 'wedding' }),
photo: expect.objectContaining({ id: 101, filename: 'wedding-001.jpg' }),
})
);
});
it('handles videos with ffmpeg metadata path', async () => {
dbModule.__setPhoto({
id: 202,
event_id: 9,
filename: 'wedding-video-001.mp4',
original_filename: 'movie.mp4',
mime_type: 'video/mp4',
media_type: 'video',
size_bytes: 99999,
captured_at: null,
});
dbModule.__setEvent({ id: 9, slug: 'wedding', event_name: 'Wedding' });
videoProcessor.processUploadedVideo.mockResolvedValueOnce({
thumbnailKey: 'thumbnails/thumb_wedding-video-001.jpg',
metadata: {
duration: 12.5,
videoCodec: 'h264',
audioCodec: 'aac',
width: 1280,
height: 720,
},
});
const { processPhoto } = require('../../src/services/photoProcessor');
await processPhoto(202);
const finalUpdate = dbModule.__recorded().updateCalls.pop();
expect(finalUpdate.data.processing_status).toBe('complete');
expect(finalUpdate.data.duration).toBe(12.5);
expect(finalUpdate.data.video_codec).toBe('h264');
expect(finalUpdate.data.thumbnail_path).toBe('thumbnails/thumb_wedding-video-001.jpg');
// Watermark queue is image-only.
expect(watermarkService.generateForPhoto).not.toHaveBeenCalled();
});
it('throws when the photo row no longer exists', async () => {
dbModule.__setPhoto(null);
dbModule.__setEvent({ id: 1 });
const { processPhoto } = require('../../src/services/photoProcessor');
await expect(processPhoto(999)).rejects.toThrow(/Photo 999 not found/);
});
});
void path; // referenced indirectly via mocks
@@ -1,85 +0,0 @@
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');
});
});
@@ -1,66 +0,0 @@
/**
* Unit tests for formatters.js — focused on the HTML-escape behaviour added
* so admin-supplied welcome messages can't inject markup into customer mail.
*/
const { escapeHtml, nl2br, formatWelcomeMessage } = require('../../src/utils/formatters');
describe('escapeHtml', () => {
it('escapes the five HTML metacharacters', () => {
expect(escapeHtml('& < > " \'')).toBe('&amp; &lt; &gt; &quot; &#39;');
});
it('returns empty string for null/undefined', () => {
expect(escapeHtml(null)).toBe('');
expect(escapeHtml(undefined)).toBe('');
});
it('coerces non-string values', () => {
expect(escapeHtml(42)).toBe('42');
});
it('escapes & before introducing new entities', () => {
expect(escapeHtml('<&>')).toBe('&lt;&amp;&gt;');
});
});
describe('nl2br', () => {
it('joins non-empty lines with <br />', () => {
expect(nl2br('a\nb\nc')).toBe('a<br />b<br />c');
});
it('normalises CRLF and CR', () => {
expect(nl2br('a\r\nb\rc')).toBe('a<br />b<br />c');
});
it('drops empty lines', () => {
expect(nl2br('a\n\n\nb')).toBe('a<br />b');
});
it('returns empty for empty input', () => {
expect(nl2br('')).toBe('');
expect(nl2br(null)).toBe('');
});
});
describe('formatWelcomeMessage', () => {
it('returns empty string for empty input', () => {
expect(formatWelcomeMessage('')).toBe('');
expect(formatWelcomeMessage(' ')).toBe('');
});
it('escapes HTML metacharacters before nl2br', () => {
expect(formatWelcomeMessage('Hello <b>world</b>'))
.toBe('Hello &lt;b&gt;world&lt;/b&gt;');
});
it('renders newlines as <br /> while keeping content escaped', () => {
expect(formatWelcomeMessage('line 1\n<script>x</script>\nline 3'))
.toBe('line 1<br />&lt;script&gt;x&lt;/script&gt;<br />line 3');
});
it('escapes ampersands and quotes that would otherwise break HTML', () => {
expect(formatWelcomeMessage('Tom & Jerry\'s "show"'))
.toBe('Tom &amp; Jerry&#39;s &quot;show&quot;');
});
});
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
{
"generic": "cursive"
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-114
View File
@@ -1,114 +0,0 @@
This directory bundles the following typefaces, each licensed under the
SIL Open Font License v1.1.
------------------------------------------------------------
Per-font copyright notices
------------------------------------------------------------
Inter
Copyright (c) 2016 The Inter Project Authors (https://github.com/rsms/inter)
Noto Sans
Copyright 2022 The Noto Project Authors (https://github.com/notofonts/latin-greek-cyrillic)
Poppins
Copyright 2020 The Poppins Project Authors (https://github.com/itfoundry/Poppins)
Jost
Copyright 2020 The Jost Project Authors (https://github.com/indestructible-type/Jost)
Montserrat
Copyright 2011 The Montserrat Project Authors (https://github.com/JulietaUla/Montserrat)
Playfair Display
Copyright 2017 The Playfair Display Project Authors (https://github.com/clauseggers/Playfair)
IBM Plex Sans
Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
Comic Neue
Copyright (c) 2014 by Craig Rozynski. All rights reserved.
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,3 +0,0 @@
{
"generic": "serif"
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+4 -4
View File
@@ -1831,8 +1831,8 @@
}
},
"nodemailer": {
"version": "7.0.7",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.7.tgz",
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"overridden": false
},
"nodemon": {
@@ -2086,8 +2086,8 @@
"version": "4.0.1"
},
"tar-fs": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.3.tgz",
"overridden": false
},
"tunnel-agent": {
-161
View File
@@ -1,161 +0,0 @@
# Security Logging Documentation
## Overview
This document describes the comprehensive security logging implemented in the PicPeak application to track authentication failures, rate limiting, and suspicious activities.
## Log Files
### 1. **security.log**
- Location: `logs/security.log`
- Contains: All security-related events (authentication, rate limiting, suspicious activity)
- Max Size: 20MB with rotation (keeps 10 files)
- Format: JSON with timestamp
### 2. **error.log**
- Location: `logs/error.log`
- Contains: All error-level logs including auth failures
- Max Size: 10MB with rotation (keeps 5 files)
### 3. **combined.log**
- Location: `logs/combined.log`
- Contains: All logs (info, warn, error)
- Max Size: 50MB with rotation (keeps 10 files)
## Security Events Logged
### Rate Limiting
When rate limits are exceeded, the following is logged:
```json
{
"timestamp": "2024-01-18 14:23:45.123",
"level": "warn",
"message": "Rate limit exceeded",
"security": true,
"ip": "192.168.1.1",
"path": "/api/admin/login",
"method": "POST",
"authenticated": false,
"userAgent": "Mozilla/5.0...",
"referer": "https://app.example.com",
"origin": "https://app.example.com",
"headers": {
"x-forwarded-for": "192.168.1.1",
"x-real-ip": "192.168.1.1"
},
"requestUrl": "/api/admin/login",
"rateLimitInfo": {
"limit": 5,
"current": 6,
"remaining": 0,
"resetTime": "2024-01-18T14:38:45.123Z"
}
}
```
### Authentication Failures
#### Admin Login Failures
- Tracked in `login_attempts` table
- Logged with: IP address, username, user agent, timestamp
- Account lockout after 5 failures in 15 minutes
#### Gallery Password Failures
- Tracked in `access_logs` table with action='login_fail'
- Logged with: event_id, IP address, user agent
- Gallery lockout after 5 failures in 15 minutes
### JWT Validation Failures
```json
{
"timestamp": "2024-01-18 14:23:45.123",
"level": "warn",
"message": "JWT validation failed",
"ip": "192.168.1.1",
"path": "/api/admin/events",
"method": "GET",
"userAgent": "Mozilla/5.0...",
"error": "TokenExpiredError",
"message": "jwt expired"
}
```
### Suspicious Activity
- Multiple IPs attempting login for same account
- Token usage from different IP than issued
- Token usage after password change
- Revoked token usage attempts
## Configuration Settings
All rate limiting settings are configurable via the admin panel:
| Setting | Default | Range | Description |
|---------|---------|-------|-------------|
| rate_limit_enabled | true | - | Enable/disable rate limiting |
| rate_limit_window_minutes | 15 | 1-60 | Time window for rate limit |
| rate_limit_max_requests | 1000 | 10-10000 | Max requests for general endpoints |
| rate_limit_auth_max_requests | 5 | 1-100 | Max requests for auth endpoints |
| rate_limit_skip_authenticated | true | - | Skip rate limit for authenticated requests |
| rate_limit_public_endpoints_only | false | - | Only rate limit public endpoints |
## Database Tables
### login_attempts
```sql
- id
- username
- ip_address
- user_agent
- success (boolean)
- created_at
```
### access_logs
```sql
- id
- event_id
- ip_address
- user_agent
- action ('view', 'download', 'login_success', 'login_fail')
- photo_id (nullable)
- created_at
```
## Environment Variables
- `LOG_LEVEL`: Set logging level (default: 'info')
- `LOG_TO_CONSOLE`: Enable console logging in production (default: false)
## Monitoring Recommendations
1. **Set up alerts for:**
- Rate limit exceeded events (possible DDoS)
- Multiple failed login attempts from same IP
- Account lockout events
- JWT validation failures spike
2. **Regular review:**
- Check security.log for patterns
- Review login_attempts table for brute force attempts
- Monitor access_logs for suspicious gallery access patterns
3. **Log analysis tools:**
- Use log aggregation tools (ELK stack, Splunk)
- Set up dashboards for security metrics
- Configure alerts for threshold breaches
## Production Deployment Notes
1. Ensure logs directory has proper permissions
2. Set up log rotation outside of application if needed
3. Consider shipping logs to centralized logging service
4. Monitor disk space for log files
5. Set `LOG_TO_CONSOLE=true` for container deployments
## Security Best Practices
1. Never log sensitive data (passwords, tokens)
2. Use generic error messages to prevent user enumeration
3. Clean up old login attempts regularly (7 days retention)
4. Monitor for unusual patterns in real-time
5. Keep rate limit settings appropriate for your usage
-6
View File
@@ -1,10 +1,4 @@
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';
}
});
+25 -102
View File
@@ -3,54 +3,6 @@ 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',
@@ -61,7 +13,7 @@ const config = {
password: process.env.DB_PASSWORD || 'postgres',
database: process.env.DB_NAME || 'photo_sharing'
} : {
filename: resolveSqliteFilename(process.env.DATABASE_PATH || './data/photo_sharing.db')
filename: path.join(__dirname, process.env.DATABASE_PATH || './data/photo_sharing.db')
},
useNullAsDefault: process.env.DATABASE_CLIENT !== 'pg',
migrations: {
@@ -72,65 +24,36 @@ 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',
// 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,
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: 2,
max: 10,
acquireTimeoutMillis: 30000,
createTimeoutMillis: 30000,
idleTimeoutMillis: 30000,
reapIntervalMillis: 1000,
createRetryIntervalMillis: 200,
propagateCreateError: false
},
migrations: {
directory: './migrations'
},
acquireConnectionTimeout: 60000
}
};
const env = process.env.NODE_ENV || 'development';
module.exports = config[env] || config.development;
module.exports = config[process.env.NODE_ENV || 'development'];
@@ -0,0 +1,101 @@
const { db } = require('../src/database/db');
async function up() {
console.log('Adding photo categories and CMS tables...');
// Create photo_categories table
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());
// Unique constraint for slug within event scope
table.unique(['slug', 'event_id']);
});
// Create cms_pages table
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());
});
// Add category_id to photos table
await db.schema.alterTable('photos', (table) => {
table.integer('category_id').references('id').inTable('photo_categories');
});
// Add language preference to admin_users
await db.schema.alterTable('admin_users', (table) => {
table.string('language', 2).defaultTo('en');
});
// Add language preference to app_settings for global default
await db('app_settings').insert({
setting_key: 'default_language',
setting_value: 'en',
setting_type: 'general',
updated_at: new Date()
});
// Insert default global categories
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);
// Insert default legal pages
await db('cms_pages').insert([
{
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()
}
]);
console.log('Photo categories and CMS tables created successfully');
}
async function down() {
// Remove language from app_settings
await db('app_settings').where('setting_key', 'default_language').delete();
// Drop columns
await db.schema.alterTable('admin_users', (table) => {
table.dropColumn('language');
});
await db.schema.alterTable('photos', (table) => {
table.dropColumn('category_id');
});
// Drop tables
await db.schema.dropTableIfExists('cms_pages');
await db.schema.dropTableIfExists('photo_categories');
}
module.exports = { up, down };
@@ -0,0 +1,28 @@
exports.up = async function(knex) {
// Add photo_counter column to photo_categories table
await knex.schema.alterTable('photo_categories', function(table) {
table.integer('photo_counter').defaultTo(0).notNullable();
});
// Initialize counters based on existing photos
const categories = await knex('photo_categories').select('id');
for (const category of categories) {
const photoCount = await knex('photos')
.where('category_id', category.id)
.count('id as count')
.first();
if (photoCount && photoCount.count > 0) {
await knex('photo_categories')
.where('id', category.id)
.update({ photo_counter: photoCount.count });
}
}
};
exports.down = async function(knex) {
await knex.schema.alterTable('photo_categories', function(table) {
table.dropColumn('photo_counter');
});
};
@@ -0,0 +1,35 @@
exports.up = async function(knex) {
// Add language-specific columns to email_templates
await knex.schema.alterTable('email_templates', function(table) {
// Add English versions (rename existing columns for consistency)
table.renameColumn('subject', 'subject_en');
table.renameColumn('body_html', 'body_html_en');
table.renameColumn('body_text', 'body_text_en');
// Add German versions
table.string('subject_de');
table.text('body_html_de');
table.text('body_text_de');
});
// Copy existing values to German columns as defaults
await knex('email_templates').update({
subject_de: knex.raw('subject_en'),
body_html_de: knex.raw('body_html_en'),
body_text_de: knex.raw('body_text_en')
});
};
exports.down = async function(knex) {
await knex.schema.alterTable('email_templates', function(table) {
// Remove German columns
table.dropColumn('subject_de');
table.dropColumn('body_html_de');
table.dropColumn('body_text_de');
// Rename columns back
table.renameColumn('subject_en', 'subject');
table.renameColumn('body_html_en', 'body_html');
table.renameColumn('body_text_en', 'body_text');
});
};
@@ -0,0 +1,23 @@
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 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
});
};
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');
});
};
@@ -8,7 +8,8 @@ exports.up = async function(knex) {
await knex('app_settings').insert({
setting_key: 'general_default_welcome_message',
setting_value: JSON.stringify('Thank you for using our photo sharing service! We hope you enjoy your photos.'),
setting_type: 'general'
setting_type: 'general',
updated_at: new Date()
});
}
@@ -1,4 +1,4 @@
const { db } = require('../../src/database/db');
const { db } = require('../src/database/db');
async function up() {
// Check if host_name column already exists
@@ -0,0 +1,19 @@
exports.up = function(knex) {
return knex.schema.createTable('login_attempts', table => {
table.increments('id').primary();
table.string('identifier').notNullable(); // username or email
table.string('ip_address', 45).notNullable(); // IPv4 or IPv6
table.text('user_agent');
table.timestamp('attempt_time').defaultTo(knex.fn.now());
table.boolean('success').defaultTo(false);
// Indexes for performance
table.index('identifier');
table.index('attempt_time');
table.index(['identifier', 'success', 'attempt_time']);
});
};
exports.down = function(knex) {
return knex.schema.dropTableIfExists('login_attempts');
};
@@ -0,0 +1,25 @@
exports.up = function(knex) {
return knex.schema.table('admin_users', table => {
// Add password change tracking
table.timestamp('password_changed_at').nullable();
// Add last login IP for security monitoring
table.string('last_login_ip', 45).nullable();
// Add account security flags
table.boolean('two_factor_enabled').defaultTo(false);
table.string('two_factor_secret').nullable();
// Add index for performance
table.index('password_changed_at');
});
};
exports.down = function(knex) {
return knex.schema.table('admin_users', table => {
table.dropColumn('password_changed_at');
table.dropColumn('last_login_ip');
table.dropColumn('two_factor_enabled');
table.dropColumn('two_factor_secret');
});
};
@@ -0,0 +1,34 @@
exports.up = function(knex) {
return knex.schema
// Table for individual token revocations
.createTable('revoked_tokens', table => {
table.increments('id').primary();
table.string('token_id').notNullable().unique(); // JWT ID or generated ID
table.integer('user_id').nullable(); // User who owned the token
table.string('token_type', 20); // admin, gallery, etc.
table.timestamp('revoked_at').defaultTo(knex.fn.now());
table.timestamp('expires_at').notNullable(); // When token would have expired
table.string('reason', 100); // password_change, logout, compromised, etc.
table.text('metadata'); // Additional JSON data
// Indexes for performance
table.index('token_id');
table.index('user_id');
table.index('expires_at'); // For cleanup
})
// Table for user-level revocations (revoke all tokens before a certain time)
.createTable('user_token_revocations', table => {
table.integer('user_id').primary();
table.timestamp('revoked_at').notNullable();
table.string('reason', 100);
// Index for quick lookups
table.index('revoked_at');
});
};
exports.down = function(knex) {
return knex.schema
.dropTableIfExists('user_token_revocations')
.dropTableIfExists('revoked_tokens');
};

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