Compare commits

..

57 Commits

Author SHA1 Message Date
Gitea Actions Bot bb00c3993b chore: bump version to 1.0.50 (backend + frontend)
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-16 12:46:54 +00:00
paul c9c0de46bf fix: improve email processor reliability and add diagnostic tools
Mirror to GitHub / mirror (push) Successful in 22s
Test and Lint / backend-test (push) Successful in 1m19s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m15s
Version and Release / version-bump (push) Successful in 37s
Version and Release / trigger-drone (push) Successful in 2s
- Enhanced email processor with comprehensive logging
- Added error handling for database queries in email processing
- Fixed duplicate export in module.exports
- Added testEmailConnection() function for connectivity testing
- Created diagnostic scripts:
  - check-email-processor.js: Full diagnostic with manual processing
  - run-email-processor.js: Standalone processor (can run once or continuously)
  - check-email-environment.js: Environment and configuration checker
- Fixed missing translation: added 'common.unknown' in EN/DE locales

This should help identify why emails aren't being processed on production.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 14:30:09 +02:00
Gitea Actions Bot 5374299cd5 chore: bump backend version to 1.0.49
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-16 09:34:18 +00:00
paul 536e2b2874 fix: handle pre-parsed JSON data in email processor
Mirror to GitHub / mirror (push) Successful in 22s
Test and Lint / backend-test (push) Successful in 1m5s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m29s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 2s
The email_data column in PostgreSQL is a JSON type that auto-parses
the data, while SQLite stores it as a string. Updated the code to
check if email_data is already an object before attempting to parse.

This fixes the "Unexpected token o in JSON at position 1" error.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 11:29:36 +02:00
Gitea Actions Bot 141acd5736 chore: bump version to 1.0.48 (backend + frontend)
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-16 09:17:20 +00:00
paul 5f4337a18d fix: resolve multiple production issues
Mirror to GitHub / mirror (push) Successful in 23s
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 3s
- Fix archive page showing "00" instead of "0" for empty photo counts
- Fix email queue PostgreSQL "updated_at" column error with migration and fallback
- Fix storage calculation discrepancies between system status and archive pages
- Add proper storage info to system status endpoint
- Fix archive path concatenation in settings endpoint

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 11:12:32 +02:00
Gitea Actions Bot fec7b687f7 chore: bump version to 1.0.47 (backend + frontend)
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-16 08:23:21 +00:00
paul cfa29ad5cb fix: GUI improvements and fixes
Mirror to GitHub / mirror (push) Successful in 19s
Test and Lint / backend-test (push) Successful in 1m12s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m6s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Successful in 2s
- Enable JSON module imports in TypeScript config to fix frontend version display
- Fix archive page showing '00' instead of '0' for empty photo counts
- Add null safety to archive photo count calculation
- Update email processor to reinitialize on config changes
- Add auto-retry for failed email transporter initialization

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 10:18:52 +02:00
Gitea Actions Bot 76ae35217c chore: bump backend version to 1.0.46
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-16 07:25:22 +00:00
paul fe651fa38e fix: add activity_logs deletion to event delete transaction
Mirror to GitHub / mirror (push) Successful in 24s
Test and Lint / backend-test (push) Successful in 1m6s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 35s
Version and Release / trigger-drone (push) Successful in 3s
- Include activity_logs table in deletion transaction to prevent foreign key constraint error
- Maintain proper deletion order: activity_logs before events
- Fixes 500 error when deleting events that have associated activity logs

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 09:20:48 +02:00
paul f7b8c0c0fe fix: resolve event deletion 500 error with proper foreign key handling
Mirror to GitHub / mirror (push) Successful in 22s
Test and Lint / backend-test (push) Successful in 1m7s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m18s
Version and Release / version-bump (push) Failing after 31s
Version and Release / trigger-drone (push) Has been skipped
- Rewrite delete endpoint to use database transaction
- Delete related data in correct order: access_logs, email_queue, photos, photo_categories, then event
- Add cleanup for storage folders and archive files
- Improve error messages for better debugging

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 08:30:05 +02:00
Gitea Actions Bot 4af3cc2486 chore: bump version to 1.0.45
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-15 20:45:45 +00:00
paul 3632b936e9 fix: improve URL slug generation for special characters
Mirror to GitHub / mirror (push) Successful in 20s
Test and Lint / backend-test (push) Successful in 1m6s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m6s
Version and Release / version-bump (push) Successful in 30s
Version and Release / trigger-drone (push) Successful in 2s
- Replace all non-alphanumeric characters with single dash
- Collapse multiple consecutive dashes into single dash
- Remove leading and trailing dashes
- Fixes issue where "Petra & Peter" became "petra---peter"
- Now generates cleaner URLs like "petra-peter" instead

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 22:41:25 +02:00
Gitea Actions Bot 66a6d4003a chore: bump version to 1.0.44
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-15 20:34:22 +00:00
paul bdf73c1f06 fix: properly allow date-based passwords for galleries
Mirror to GitHub / mirror (push) Successful in 23s
Test and Lint / backend-test (push) Successful in 1m6s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 31s
Version and Release / trigger-drone (push) Successful in 3s
- Added skipStrengthCheck option to bypass zxcvbn analysis for gallery passwords
- Added explicit date pattern matching for formats like "04.07.2025"
- Date passwords (DD.MM.YYYY, DD/MM/YYYY, DD-MM-YYYY) are now automatically accepted
- Gallery passwords skip all strength requirements but maintain 6 character minimum
- Fixes production issue where date passwords were rejected by zxcvbn

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 22:29:58 +02:00
Gitea Actions Bot f032743690 chore: bump version to 1.0.43
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 16:52:20 +00:00
paul a9902b95b4 fix: allow date formats as gallery passwords
Mirror to GitHub / mirror (push) Successful in 21s
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 32s
Version and Release / trigger-drone (push) Successful in 3s
- Modified backend password validation for galleries to be more lenient
- Reduced minimum password length to 6 characters for galleries
- Removed uppercase/lowercase/number requirements for gallery passwords
- Allow date formats like "04.07.2025" as passwords
- Added helper text to inform users about password options
- Still prevent overly simple passwords like "123456"
- Admin passwords remain strict with all security requirements

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 18:47:48 +02:00
Gitea Actions Bot 9d1c0b672a chore: bump version to 1.0.42
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 16:40:19 +00:00
paul 727fd8bae8 fix: allow past dates when creating events
Mirror to GitHub / mirror (push) Successful in 20s
Test and Lint / backend-test (push) Successful in 1m13s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m24s
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 3s
- Removed min date restriction from event date picker
- Users can now select any date (past, present, or future)
- Essential for uploading events that have already occurred

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 18:35:30 +02:00
Gitea Actions Bot 954103510a chore: bump version to 1.0.41
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 15:14:40 +00:00
paul 801e1f81d9 fix: improve gallery lightbox UI for better user experience
Mirror to GitHub / mirror (push) Successful in 24s
Test and Lint / backend-test (push) Successful in 1m12s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m22s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 3s
- Removed photo filename display from lightbox for cleaner viewing
- Fixed z-index layering to ensure controls always appear above images
- Changed control elements from z-10 to z-20 for proper visibility
- Set image container to z-0 to prevent overlap with controls
- Ensures all buttons remain accessible regardless of image size or zoom

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 17:09:37 +02:00
paul f9861480aa fix: improve GitHub mirror workflow reliability
Mirror to GitHub / mirror (push) Successful in 22s
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m14s
- Added workflow_dispatch for manual triggering
- Added debugging steps to identify issues
- Clean up existing branches before creating new ones
- Remove existing remotes to avoid conflicts
- Added GitHub token verification
- Added completion status messages
- Made workflow more resilient with better error handling

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 16:30:22 +02:00
Gitea Actions Bot a26dfd3d6f chore: bump version to 1.0.40
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 14:21:15 +00:00
paul 1db908771f fix: resolve multiple UI issues in admin panel
Test and Lint / backend-test (push) Successful in 1m11s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 3s
- Fixed dropdown menu visibility in events table by using fixed positioning
- Fixed double /api prefix in settings upload endpoints (favicon, watermark)
- Fixed thumbnail display in hero image selection by properly handling API paths
- Removed unnecessary console.log statements
- Added proper cleanup for dropdown on scroll/resize events

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 16:16:47 +02:00
Gitea Actions Bot 59651b8c24 chore: bump version to 1.0.39
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 11:04:23 +00:00
paul 7ccd48297f fix: resolve dropdown menu visibility issue in admin events table
Test and Lint / backend-test (push) Successful in 1m7s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m18s
Version and Release / version-bump (push) Successful in 38s
Version and Release / trigger-drone (push) Successful in 3s
- Changed Card container from overflow-hidden to overflow-visible
- Added overflow-y-visible to prevent vertical clipping
- Dropdown menu now properly displays even with few events
- Table only scrolls when content exceeds viewport height

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 12:59:54 +02:00
Gitea Actions Bot d05ff6380e chore: bump version to 1.0.38
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 09:51:55 +00:00
paul 605f773a7e fix: resolve gallery photo/thumbnail serving issues
Test and Lint / backend-test (push) Successful in 1m13s
continuous-integration/drone/push Build is passing
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
- Change gallery photo URLs from static paths to API endpoints
- Add dedicated thumbnail serving endpoint for galleries
- Add test script to diagnose authentication issues
- Add nginx configuration documentation for Authorization header

This fixes the issue where photos and thumbnails work in admin but not
in gallery view. The problem was that static file routes with auth
middleware often have Authorization headers stripped by reverse proxies.
Using API endpoints ensures proper authentication handling.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 11:47:53 +02:00
Gitea Actions Bot c844f634c8 chore: bump version to 1.0.37
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 09:37:32 +00:00
paul 1d94398e2d fix: add thumbnail cleanup on archive deletion and diagnostic scripts
Test and Lint / backend-test (push) Successful in 1m8s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m23s
Version and Release / version-bump (push) Successful in 32s
Version and Release / trigger-drone (push) Successful in 3s
- Fix archive deletion to also clean up associated thumbnails
- Add cleanup-thumbnails.js script to remove temporary and orphaned thumbnails
- Add diagnose-thumbnails.js script to troubleshoot thumbnail serving issues
- Update README with documentation for new scripts

This prevents thumbnail accumulation when events are deleted and helps
diagnose why thumbnails might not be showing despite existing on disk.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 11:33:11 +02:00
Gitea Actions Bot a2551dc0ad chore: bump version to 1.0.36
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-15 09:27:09 +00:00
paul 32821934e6 feat: add storage management scripts for production troubleshooting
Test and Lint / backend-test (push) Successful in 1m13s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 37s
Version and Release / trigger-drone (push) Successful in 4s
- Add check-storage.js to verify storage structure and file existence
- Add regenerate-thumbnails.js to recreate missing thumbnail files
- Include comprehensive README with usage instructions
- Scripts help diagnose and fix missing photo/thumbnail issues

These scripts are essential for production maintenance when photos
or thumbnails are missing from the storage directory.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 11:23:00 +02:00
Gitea Actions Bot b9c28e52cd chore: bump version to 1.0.35
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-15 07:15:42 +00:00
paul c94b6268cf fix: resolve gallery authentication and redirect issues
Test and Lint / backend-test (push) Failing after 32s
Test and Lint / frontend-test (push) Successful in 2m15s
continuous-integration/drone/push Build is passing
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Successful in 4s
- Fix useWatermarkSettings hook to use public API endpoint instead of admin endpoint
- Add hero_photo_id to gallery authentication response
- Prevent 401 errors on gallery pages from redirecting to admin login
- Gallery pages now correctly fetch settings without requiring admin auth

The main issue was that gallery pages were calling admin-only endpoints
which triggered 401 errors and caused redirects to the admin login page.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-15 09:11:50 +02:00
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
94 changed files with 2662 additions and 3404 deletions
@@ -1,39 +0,0 @@
name: Mirror to GitHub (Archive Method)
on:
push:
branches:
- main
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Mirror using git archive
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Configure git
git config --global user.name "Gitea Mirror Bot"
git config --global user.email "bot@noreply.gitea.local"
# Copy gitattributes
cp .gitattributes-github .gitattributes
# Create archive excluding files
git archive --format=tar HEAD | tar -x -C /tmp/export
# Initialize new repo in export directory
cd /tmp/export
git init
git add .
git commit -m "Mirror from Gitea: $(date '+%Y-%m-%d %H:%M:%S')"
# Push to GitHub
git remote add origin https://x-access-token:${GITHUB_TOKEN}@github.com/YOUR_GITHUB_USERNAME/YOUR_REPO_NAME.git
git push -f origin main
-42
View File
@@ -1,42 +0,0 @@
name: Mirror to GitHub (Rsync Method)
on:
push:
branches:
- main
jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
- name: Prepare mirror directory
run: |
# Create mirror directory
mkdir -p /tmp/github-mirror
# Use rsync to copy files, excluding sensitive ones
rsync -av --exclude-from='.github-mirror-exclude' ./ /tmp/github-mirror/
- name: Push to GitHub
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
cd /tmp/github-mirror
# Initialize git repo
git init
git config user.name "Gitea Mirror Bot"
git config user.email "bot@noreply.gitea.local"
# Add all files and commit
git add .
git commit -m "Mirror from Gitea: $(git --git-dir=$GITHUB_WORKSPACE/.git log -1 --format='%h %s')"
# Push to GitHub
git remote add origin https://x-access-token:${GITHUB_TOKEN}@github.com/YOUR_GITHUB_USERNAME/YOUR_REPO_NAME.git
git push -f origin main
+45 -3
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- main
workflow_dispatch: # Allow manual triggering
jobs:
mirror:
@@ -19,10 +20,22 @@ jobs:
git config --global user.name "the-luap"
git config --global user.email "paul-nothaft@hotmail.de"
- name: Debug - Show current branch and status
run: |
echo "Current branch:"
git branch -a
echo "Git status:"
git status
echo "Remote info:"
git remote -v
- name: Create filtered branch
run: |
# Clean up any existing github-mirror branch
git branch -D github-mirror || true
# Create a new branch for GitHub
git checkout -b github-mirror
git checkout --orphan github-mirror
# Remove sensitive files/directories
# Example: Remove .env files, private configs, etc.
@@ -41,17 +54,46 @@ jobs:
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
git rm -r --cached frontend/.claudedocs/ || true
git rm -r --cached test-maintenance.sh || true
git rm -r --cached storage/ || true
# Commit the changes
git commit -m "Remove sensitive files for GitHub mirror" || true
- name: Check GitHub token
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
run: |
if [ -z "$GITHUBTOKEN" ]; then
echo "ERROR: GITHUBTOKEN secret is not set!"
exit 1
else
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
fi
- name: Push to GitHub
env:
GITHUB_TOKEN: ${{ secrets.GITHUBTOKEN }}
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
run: |
# Remove existing github remote if it exists
git remote remove github || true
# Add GitHub remote
git remote add github https://x-access-token:${GITHUBTOKEN}@github.com/the-luap/picpeak.git
# Verify remote was added
echo "GitHub remote added:"
git remote -v
# Force push the filtered branch to GitHub main
git push github github-mirror:main --force
echo "Pushing to GitHub..."
git push github github-mirror:main --force
echo "Push completed successfully!"
- name: Workflow completed
run: |
echo "✅ Mirror to GitHub workflow completed successfully!"
echo "Check https://github.com/the-luap/picpeak to verify the mirror."
+137 -16
View File
@@ -14,6 +14,7 @@ jobs:
outputs:
new_version: ${{ steps.version.outputs.new_version }}
version_changed: ${{ steps.version.outputs.version_changed }}
component_changed: ${{ steps.version.outputs.component_changed }}
steps:
- uses: actions/checkout@v3
with:
@@ -30,15 +31,104 @@ jobs:
git config --global user.name 'Gitea Actions Bot'
git config --global user.email 'actions@gitea.local'
- name: Bump version
- name: Detect changes and 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"
set -e # Exit on error
# Split version into parts
IFS='.' read -r -a version_parts <<< "$CURRENT_VERSION"
echo "=== Debug Info ==="
echo "GitHub event before: ${{ github.event.before }}"
echo "GitHub SHA: ${{ github.sha }}"
echo "Current directory: $(pwd)"
echo "Git log (last 5): $(git log --oneline -5)"
# Get the commit range for changed files
if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ] && [ "${{ github.event.before }}" != "" ]; then
COMMIT_RANGE="${{ github.event.before }}..${{ github.sha }}"
echo "Using commit range: $COMMIT_RANGE"
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
else
# First commit or no previous commit, check against HEAD~1 if it exists
if git rev-parse HEAD~1 >/dev/null 2>&1; then
COMMIT_RANGE="HEAD~1..HEAD"
echo "Using commit range: $COMMIT_RANGE"
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
else
echo "First commit detected, checking all files"
CHANGED_FILES=$(git ls-files)
fi
fi
echo "Changed files:"
echo "$CHANGED_FILES"
# Check what changed (using echo to pipe to grep to avoid grep exit codes)
BACKEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^backend/' || echo "0")
FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^frontend/' || echo "0")
ROOT_CHANGED=$(echo "$CHANGED_FILES" | grep -c -E '^(package\.json|docker-compose|Dockerfile|scripts/)' || echo "0")
echo "Backend files changed: $BACKEND_CHANGED"
echo "Frontend files changed: $FRONTEND_CHANGED"
echo "Root files changed: $ROOT_CHANGED"
# Get current versions
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.0.0")
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.0.0")
echo "Current backend version: $BACKEND_VERSION"
echo "Current frontend version: $FRONTEND_VERSION"
# Determine what to update based on changes
BACKEND_UPDATE=false
FRONTEND_UPDATE=false
COMPONENT_CHANGED="none"
if [ "$ROOT_CHANGED" -gt 0 ]; then
# Root changes affect both components
BACKEND_UPDATE=true
FRONTEND_UPDATE=true
COMPONENT_CHANGED="both"
SOURCE_VERSION=$BACKEND_VERSION
echo "Root changes detected - updating both components"
elif [ "$BACKEND_CHANGED" -gt 0 ] && [ "$FRONTEND_CHANGED" -gt 0 ]; then
# Both components changed
BACKEND_UPDATE=true
FRONTEND_UPDATE=true
COMPONENT_CHANGED="both"
# Use the higher version as source
if [ "$(printf '%s\n' "$BACKEND_VERSION" "$FRONTEND_VERSION" | sort -V | tail -n1)" = "$BACKEND_VERSION" ]; then
SOURCE_VERSION=$BACKEND_VERSION
else
SOURCE_VERSION=$FRONTEND_VERSION
fi
echo "Both backend and frontend changed - updating both"
elif [ "$BACKEND_CHANGED" -gt 0 ]; then
# Only backend changed
BACKEND_UPDATE=true
COMPONENT_CHANGED="backend"
SOURCE_VERSION=$BACKEND_VERSION
echo "Only backend changed - updating backend"
elif [ "$FRONTEND_CHANGED" -gt 0 ]; then
# Only frontend changed
FRONTEND_UPDATE=true
COMPONENT_CHANGED="frontend"
SOURCE_VERSION=$FRONTEND_VERSION
echo "Only frontend changed - updating frontend"
else
echo "No relevant changes detected"
echo "version_changed=false" >> $GITHUB_OUTPUT
echo "component_changed=none" >> $GITHUB_OUTPUT
echo "new_version=" >> $GITHUB_OUTPUT
exit 0
fi
echo "Component changed: $COMPONENT_CHANGED"
echo "Source version: $SOURCE_VERSION"
echo "Backend update: $BACKEND_UPDATE"
echo "Frontend update: $FRONTEND_UPDATE"
# Calculate new version
IFS='.' read -r -a version_parts <<< "$SOURCE_VERSION"
MAJOR="${version_parts[0]}"
MINOR="${version_parts[1]}"
PATCH="${version_parts[2]}"
@@ -49,14 +139,23 @@ jobs:
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "component_changed=$COMPONENT_CHANGED" >> $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 ..
# Update versions in package.json files
if [ "$BACKEND_UPDATE" = true ]; then
echo "Updating backend version to $NEW_VERSION"
cd backend && npm version $NEW_VERSION --no-git-tag-version
cd ..
fi
# Check if there are changes
if [[ -n $(git status -s) ]]; then
if [ "$FRONTEND_UPDATE" = true ]; then
echo "Updating frontend version to $NEW_VERSION"
cd frontend && npm version $NEW_VERSION --no-git-tag-version
cd ..
fi
# Check if there are changes to commit
if [[ -n $(git status --porcelain) ]]; then
echo "version_changed=true" >> $GITHUB_OUTPUT
else
echo "version_changed=false" >> $GITHUB_OUTPUT
@@ -65,15 +164,36 @@ jobs:
- 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 }}"
COMPONENT="${{ steps.version.outputs.component_changed }}"
if [ "$COMPONENT" = "both" ]; then
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 }} (backend + frontend)"
elif [ "$COMPONENT" = "backend" ]; then
git add backend/package.json backend/package-lock.json
git commit -m "chore: bump backend version to ${{ steps.version.outputs.new_version }}"
elif [ "$COMPONENT" = "frontend" ]; then
git add frontend/package.json frontend/package-lock.json
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
fi
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 }}"
COMPONENT="${{ steps.version.outputs.component_changed }}"
if [ "$COMPONENT" = "both" ]; then
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend + frontend)"
elif [ "$COMPONENT" = "backend" ]; then
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend)"
elif [ "$COMPONENT" = "frontend" ]; then
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (frontend)"
fi
git tag -a "v${{ steps.version.outputs.new_version }}" -m "$TAG_MESSAGE"
git push origin "v${{ steps.version.outputs.new_version }}"
trigger-drone:
@@ -84,5 +204,6 @@ jobs:
- name: Trigger Drone Build
run: |
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
echo "Drone will automatically trigger on the new tag"
# Drone CI will automatically trigger on the tag push event
-66
View File
@@ -1,66 +0,0 @@
# Changelog
All notable changes to PicPeak will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Gitea workflows for selective GitHub mirroring
- Password visibility toggle on event creation form
- Translation for password requirement errors
### Changed
- Relaxed password requirements from 12 to 8 characters minimum
- Made special characters optional for gallery passwords
- Improved password strength requirements for better usability
### Fixed
- Production deployment issues with Traefik routing
- Database migration failures for email_queue table
- JSON parsing errors in production environment
- PostgreSQL compatibility issues
- Connection stability in production
## [1.0.22] - 2024-01-14
### Added
- Comprehensive error boundaries for better error handling
- Skeleton loading screens for improved perceived performance
- Offline indicator for network status
- Keyboard navigation support in gallery lightbox
- Skip links for accessibility
- Focus trap management for modals
### Changed
- Improved accessibility to WCAG 2.1 AA compliance
- Enhanced loading states with skeleton screens
- Better error recovery with component-level boundaries
### Fixed
- Missing translations in German locale
- Session timeout caching issues
- Email template JSON parsing errors
## [1.0.0] - 2024-01-01
### Added
- Initial release of PicPeak
- Photo gallery management system
- Automatic file watching and gallery creation
- Password-protected galleries
- Expiration system with email notifications
- Admin dashboard with analytics
- Multi-language support (EN, DE)
- Docker deployment support
- Email template customization
- User upload functionality
- Bulk download features
- Mobile-responsive design
- Theme customization options
[Unreleased]: https://github.com/the-luap/picpeak/compare/v1.0.22...HEAD
[1.0.22]: https://github.com/the-luap/picpeak/compare/v1.0.0...v1.0.22
[1.0.0]: https://github.com/the-luap/picpeak/releases/tag/v1.0.0
-130
View File
@@ -1,130 +0,0 @@
# 🚀 Quick Local Development Setup
Get the photo sharing platform running locally in under 2 minutes!
## Prerequisites
- Docker Desktop installed and running
- Git
- 4GB RAM available
## Quick Start
```bash
# 1. Clone the repository
git clone <your-repo-url>
cd picpeak
# 2. Start everything
./start-local.sh
```
That's it! 🎉
## What You Get
| Service | URL | Description |
|---------|-----|-------------|
| Frontend (Dev) | http://localhost:3002 | React app with hot reload |
| Frontend (Prod) | http://localhost:3000 | Production build |
| Backend API | http://localhost:3001 | Express API |
| Mailhog | http://localhost:8025 | Email testing UI |
## Default Credentials
- **Admin Login**: Check `ADMIN_CREDENTIALS.txt` after first setup
- **Test Gallery**:
- Create via Admin Panel
- Set your own secure password
## Common Tasks
### View Logs
```bash
docker-compose -f docker-compose.local.yml logs -f
```
### Stop Everything
```bash
./stop-local.sh
```
### Reset Database
```bash
docker-compose -f docker-compose.local.yml exec backend npm run migrate
```
### Add Test Photos
1. Create a gallery in the admin panel
2. Get the gallery slug (e.g., `wedding-smith-2024`)
3. Add photos to: `./storage/events/active/wedding-smith-2024/`
4. Photos appear automatically!
### Access Backend Shell
```bash
docker-compose -f docker-compose.local.yml exec backend sh
```
## Development Workflow
1. **Frontend Development** (Port 3002)
- Hot reload enabled
- Edit files in `./frontend/src`
- Changes appear instantly
2. **Backend Development** (Port 3001)
- Nodemon watches for changes
- Edit files in `./backend/src`
- Server restarts automatically
3. **Email Testing**
- All emails go to Mailhog
- View at http://localhost:8025
- No real emails sent!
## Troubleshooting
### Backend won't start
```bash
# Check logs
docker-compose -f docker-compose.local.yml logs backend
# Rebuild
docker-compose -f docker-compose.local.yml build backend
```
### Frontend build issues
```bash
# Clear cache and rebuild
docker-compose -f docker-compose.local.yml exec frontend-dev npm run build
```
### Port conflicts
Edit `docker-compose.local.yml` and change the port mappings:
- Backend: Change `3001:3000` to `XXXX:3000`
- Frontend: Change `3002:5173` to `YYYY:5173`
### Reset everything
```bash
# Stop and remove all data
docker-compose -f docker-compose.local.yml down -v
rm -rf data storage logs
./start-local.sh
```
## Tips
- 📧 Check Mailhog for all emails
- 🔄 Frontend auto-refreshes on save
- 📁 SQLite DB at `./data/photo_sharing.db`
- 🖼️ Photos in `./storage/events/active/`
- 📝 Logs in `./logs/`
## Next Steps
1. Create your first gallery via Admin Panel
2. Upload some test photos
3. Test the gallery with password
4. Check expiration warnings
5. View emails in Mailhog
Happy coding! 🎨
+34 -13
View File
@@ -1,13 +1,17 @@
# 📸 PicPeak - Open Source Photo Sharing for Events
[![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/)
<div align="center">
<img src="docs/picpeak-logo.png" alt="PicPeak Logo" width="300" />
[![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/)
</div>
**PicPeak** is a powerful, self-hosted open-source alternative to commercial photo-sharing platforms like PicDrop.com and Scrapbook.de. Designed specifically for photographers and event organizers, PicPeak makes it simple to share beautiful, time-limited photo galleries with clients while maintaining full control over your data and branding.
![PicPeak Gallery Preview](https://github.com/the-luap/picpeak/assets/placeholder-hero.png)
![PicPeak Gallery Preview](docs/screenshot-gallery.png)
## 🌟 Why Choose PicPeak?
@@ -124,17 +128,34 @@ 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>View Gallery Examples</summary>
<summary>👆 Click to see more interface details</summary>
### Admin Dashboard
![Admin Dashboard](https://github.com/the-luap/picpeak/assets/placeholder-admin.png)
#### What makes PicPeak's interface special:
### Client Gallery View
![Gallery View](https://github.com/the-luap/picpeak/assets/placeholder-gallery.png)
### Mobile Experience
![Mobile View](https://github.com/the-luap/picpeak/assets/placeholder-mobile.png)
- **🎨 Clean Design**: Modern, photographer-friendly interface
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
- **⚡ Fast Loading**: Optimized for quick photo browsing
- **🔒 Secure Access**: Password-protected galleries with expiration
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
</details>
+3
View File
@@ -1,5 +1,8 @@
FROM node:18-alpine AS builder
# Add build argument for cache busting
ARG CACHEBUST=1
WORKDIR /app
# Copy package files
Binary file not shown.
@@ -0,0 +1,22 @@
/**
* Ensure PostgreSQL compatibility for all insert operations
* This migration doesn't change the schema but ensures all tables
* are compatible with .returning() syntax
*/
exports.up = async function(knex) {
// This migration is informational only
// All insert operations should use .returning('id') going forward
console.log('PostgreSQL compatibility check:');
console.log('- All INSERT operations should use .returning("id")');
console.log('- All date operations should use ISO strings');
console.log('- Boolean values are handled automatically by Knex');
return Promise.resolve();
};
exports.down = async function(knex) {
// No rollback needed
return Promise.resolve();
};
@@ -0,0 +1,29 @@
/**
* Fix boolean compatibility issues between PostgreSQL and SQLite
* This migration updates the database configuration and existing data
*/
exports.up = async function(knex) {
const isPostgres = knex.client.config.client === 'pg';
if (!isPostgres) {
// Enable foreign keys for SQLite
await knex.raw('PRAGMA foreign_keys = ON');
// Note: SQLite stores booleans as 0/1
// No data migration needed as Knex handles this automatically
// But queries must use formatBoolean() helper
console.log('SQLite boolean compatibility check:');
console.log('- SQLite stores booleans as 0/1');
console.log('- All boolean comparisons should use formatBoolean() helper');
console.log('- Foreign keys enabled');
}
return Promise.resolve();
};
exports.down = async function(knex) {
// No rollback needed
return Promise.resolve();
};
@@ -0,0 +1,33 @@
/**
* Fix email_queue table by ensuring it doesn't have updated_at column
* This migration addresses the PostgreSQL error where queries are trying to update
* a non-existent updated_at column
*/
exports.up = async function(knex) {
// First, check if the column exists
const hasUpdatedAt = await knex.schema.hasColumn('email_queue', 'updated_at');
if (hasUpdatedAt) {
console.log('Found updated_at column in email_queue table, removing it...');
await knex.schema.table('email_queue', (table) => {
table.dropColumn('updated_at');
});
}
// Also ensure the table has all required columns
const hasCreatedAt = await knex.schema.hasColumn('email_queue', 'created_at');
if (!hasCreatedAt) {
console.log('Adding missing created_at column to email_queue table...');
await knex.schema.table('email_queue', (table) => {
table.datetime('created_at').defaultTo(knex.fn.now());
});
}
console.log('email_queue table schema fixed');
};
exports.down = async function(knex) {
// In the down migration, we don't add back updated_at since it shouldn't exist
// This is intentionally left minimal
};
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.24",
"version": "1.0.50",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.24",
"version": "1.0.50",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.24",
"version": "1.0.50",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
+131
View File
@@ -0,0 +1,131 @@
const { db } = require('../src/database/db');
async function checkEmailEnvironment() {
console.log('=== Email Environment Check ===\n');
// 1. Check environment variables
console.log('1. Environment Variables:');
const envVars = [
'SMTP_HOST',
'SMTP_PORT',
'SMTP_USER',
'SMTP_PASS',
'SMTP_FROM',
'SMTP_SECURE',
'EMAIL_PROCESSOR_ENABLED',
'NODE_ENV'
];
envVars.forEach(varName => {
const value = process.env[varName];
if (varName.includes('PASS')) {
console.log(` ${varName}: ${value ? '***' : 'NOT SET'}`);
} else {
console.log(` ${varName}: ${value || 'NOT SET'}`);
}
});
// 2. Check database configuration
console.log('\n2. Database Email Configuration:');
try {
const emailConfig = await db('email_configs').first();
if (emailConfig) {
console.log(' Email configuration found in database:');
console.log(` - SMTP Host: ${emailConfig.smtp_host}`);
console.log(` - SMTP Port: ${emailConfig.smtp_port}`);
console.log(` - SMTP User: ${emailConfig.smtp_user || 'NOT SET'}`);
console.log(` - SMTP Secure: ${emailConfig.smtp_secure}`);
console.log(` - From Address: ${emailConfig.smtp_from}`);
} else {
console.log(' ⚠️ No email configuration found in database!');
console.log(' This will prevent the email processor from initializing.');
}
} catch (error) {
console.log(` ❌ Error reading email configuration: ${error.message}`);
}
// 3. Check if the email processor should be disabled
console.log('\n3. Email Processor Status:');
const isDisabled = process.env.EMAIL_PROCESSOR_ENABLED === 'false';
if (isDisabled) {
console.log(' ⚠️ Email processor is DISABLED via EMAIL_PROCESSOR_ENABLED=false');
} else {
console.log(' ✅ Email processor is enabled (default)');
}
// 4. Check pending emails
console.log('\n4. Email Queue Status:');
try {
const pending = await db('email_queue')
.where('status', 'pending')
.count('* as count')
.first();
const failed = await db('email_queue')
.where('status', 'failed')
.where('retry_count', '>=', 3)
.count('* as count')
.first();
const sent = await db('email_queue')
.where('status', 'sent')
.count('* as count')
.first();
console.log(` - Pending emails: ${pending.count}`);
console.log(` - Failed emails (max retries): ${failed.count}`);
console.log(` - Sent emails: ${sent.count}`);
} catch (error) {
console.log(` ❌ Error querying email queue: ${error.message}`);
}
// 5. Test database connection
console.log('\n5. Database Connection:');
try {
await db.raw('SELECT 1');
console.log(' ✅ Database connection successful');
} catch (error) {
console.log(` ❌ Database connection failed: ${error.message}`);
}
// 6. Check for any recent errors
console.log('\n6. Recent Email Errors:');
try {
const recentErrors = await db('email_queue')
.whereNotNull('error_message')
.orderBy('id', 'desc')
.limit(3)
.select('id', 'email_type', 'error_message', 'retry_count');
if (recentErrors.length > 0) {
recentErrors.forEach((email, index) => {
console.log(` ${index + 1}. Email ID ${email.id} (${email.email_type}):`);
console.log(` Retries: ${email.retry_count}`);
console.log(` Error: ${email.error_message}`);
});
} else {
console.log(' No recent errors found');
}
} catch (error) {
console.log(` ❌ Error querying recent errors: ${error.message}`);
}
console.log('\n=== Environment check complete ===');
console.log('\nRecommendations:');
const emailConfig = await db('email_configs').first().catch(() => null);
if (!emailConfig) {
console.log('❗ Configure email settings in the admin panel or add email_configs record');
}
if (!process.env.SMTP_HOST && !emailConfig) {
console.log('❗ Set SMTP environment variables or configure in database');
}
await db.destroy();
}
checkEmailEnvironment().catch(error => {
console.error('Fatal error:', error);
process.exit(1);
});
+157
View File
@@ -0,0 +1,157 @@
const { db } = require('../src/database/db');
const winston = require('winston');
// Create a simple console logger
const logger = winston.createLogger({
format: winston.format.simple(),
transports: [new winston.transports.Console()]
});
async function checkEmailProcessor() {
try {
logger.info('=== Email Processor Diagnostic Check ===\n');
// 1. Check pending emails
logger.info('1. Checking pending emails in queue...');
const pendingEmails = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
.orderBy('created_at', 'asc');
logger.info(`Found ${pendingEmails.length} pending emails\n`);
if (pendingEmails.length > 0) {
logger.info('Pending email details:');
pendingEmails.forEach((email, index) => {
logger.info(`\nEmail ${index + 1}:`);
logger.info(` ID: ${email.id}`);
logger.info(` Type: ${email.email_type}`);
logger.info(` Recipient: ${email.recipient_email}`);
logger.info(` Event ID: ${email.event_id}`);
logger.info(` Status: ${email.status}`);
logger.info(` Retry Count: ${email.retry_count}`);
logger.info(` Scheduled At: ${email.scheduled_at}`);
logger.info(` Created At: ${email.created_at}`);
logger.info(` Error: ${email.error_message || 'None'}`);
// Check if email_data needs parsing
logger.info(` Email Data Type: ${typeof email.email_data}`);
if (email.email_data) {
try {
const data = typeof email.email_data === 'string'
? JSON.parse(email.email_data)
: email.email_data;
logger.info(` Email Data Keys: ${Object.keys(data).join(', ')}`);
} catch (e) {
logger.error(` Failed to parse email_data: ${e.message}`);
}
}
});
}
// 2. Check failed emails
logger.info('\n\n2. Checking failed emails...');
const failedEmails = await db('email_queue')
.where('status', 'failed')
.orderBy('created_at', 'desc')
.limit(5);
logger.info(`Found ${failedEmails.length} failed emails (showing last 5)\n`);
if (failedEmails.length > 0) {
failedEmails.forEach((email, index) => {
logger.info(`\nFailed Email ${index + 1}:`);
logger.info(` ID: ${email.id}`);
logger.info(` Type: ${email.email_type}`);
logger.info(` Retry Count: ${email.retry_count}`);
logger.info(` Error: ${email.error_message || 'No error message'}`);
logger.info(` Last Attempt: ${email.sent_at || 'Never'}`);
});
}
// 3. Check if email processor should be running
logger.info('\n\n3. Checking email processor configuration...');
// Check environment variables
const emailConfig = {
SMTP_HOST: process.env.SMTP_HOST,
SMTP_PORT: process.env.SMTP_PORT,
SMTP_USER: process.env.SMTP_USER,
SMTP_FROM: process.env.SMTP_FROM,
SMTP_SECURE: process.env.SMTP_SECURE,
EMAIL_PROCESSOR_ENABLED: process.env.EMAIL_PROCESSOR_ENABLED || 'true'
};
logger.info('Email configuration:');
Object.entries(emailConfig).forEach(([key, value]) => {
if (key === 'SMTP_USER') {
logger.info(` ${key}: ${value ? '***' : 'NOT SET'}`);
} else {
logger.info(` ${key}: ${value || 'NOT SET'}`);
}
});
// 4. Test email processor functionality
logger.info('\n\n4. Testing email processor functionality...');
// Import the email processor
const { processEmailQueue, testEmailConnection } = require('../src/services/emailProcessor');
// Test email connection
logger.info('Testing email connection...');
try {
const connectionTest = await testEmailConnection();
logger.info(`Email connection test: ${connectionTest ? 'SUCCESS' : 'FAILED'}`);
} catch (error) {
logger.error(`Email connection test failed: ${error.message}`);
}
// Try to process queue once manually
if (pendingEmails.length > 0) {
logger.info('\n\n5. Attempting to process email queue manually...');
try {
await processEmailQueue();
logger.info('Manual queue processing completed');
// Check status after processing
const stillPending = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
.count('* as count')
.first();
logger.info(`Emails still pending after processing: ${stillPending.count}`);
} catch (error) {
logger.error(`Error processing queue: ${error.message}`);
logger.error(`Stack trace: ${error.stack}`);
}
}
// 5. Check for any recent successful emails
logger.info('\n\n6. Checking recent successful emails...');
const recentSuccess = await db('email_queue')
.where('status', 'sent')
.orderBy('sent_at', 'desc')
.limit(3);
if (recentSuccess.length > 0) {
logger.info(`Last ${recentSuccess.length} successful emails:`);
recentSuccess.forEach((email, index) => {
logger.info(` ${index + 1}. Type: ${email.email_type}, Sent: ${email.sent_at}`);
});
} else {
logger.info('No successfully sent emails found');
}
logger.info('\n\n=== Diagnostic check complete ===');
} catch (error) {
logger.error('Error running diagnostic check:', error);
} finally {
await db.destroy();
process.exit(0);
}
}
// Run the check
checkEmailProcessor();
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* Script to check storage directory structure and verify files
* Usage: node scripts/check-storage.js [eventSlug]
*/
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../src/database/db');
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
async function checkDirectory(dirPath, description) {
try {
await fs.access(dirPath);
const stats = await fs.stat(dirPath);
const files = await fs.readdir(dirPath);
console.log(`${description}: ${dirPath}`);
console.log(` - Files/Folders: ${files.length}`);
console.log(` - Permissions: ${(stats.mode & parseInt('777', 8)).toString(8)}`);
return true;
} catch (error) {
console.log(`${description}: ${dirPath} - ${error.message}`);
return false;
}
}
async function checkStorageStructure(eventSlug = null) {
console.log('Checking storage structure...');
console.log(`Storage base path: ${STORAGE_PATH}\n`);
// Check main directories
await checkDirectory(STORAGE_PATH, 'Storage root');
await checkDirectory(path.join(STORAGE_PATH, 'events'), 'Events directory');
await checkDirectory(path.join(STORAGE_PATH, 'events/active'), 'Active events');
await checkDirectory(path.join(STORAGE_PATH, 'events/archived'), 'Archived events');
await checkDirectory(path.join(STORAGE_PATH, 'thumbnails'), 'Thumbnails');
await checkDirectory(path.join(STORAGE_PATH, 'uploads'), 'Uploads');
console.log('\n---\n');
// If event slug provided, check specific event
if (eventSlug) {
console.log(`Checking specific event: ${eventSlug}`);
const event = await db('events').where('slug', eventSlug).first();
if (!event) {
console.log(`✗ Event not found in database: ${eventSlug}`);
return;
}
console.log(`✓ Event found in database:`);
console.log(` - ID: ${event.id}`);
console.log(` - Name: ${event.event_name}`);
console.log(` - Active: ${event.is_active}`);
console.log(` - Archived: ${event.is_archived}`);
// Check event directory
const eventDir = path.join(STORAGE_PATH, 'events/active', eventSlug);
const eventExists = await checkDirectory(eventDir, 'Event directory');
if (eventExists) {
const files = await fs.readdir(eventDir);
console.log(` - Photo files: ${files.filter(f => /\.(jpg|jpeg|png|gif)$/i.test(f)).length}`);
}
// Check photos in database
const photos = await db('photos').where('event_id', event.id).select('id', 'filename', 'path', 'thumbnail_path');
console.log(`\nDatabase photos: ${photos.length}`);
// Check if photo files exist
let existingPhotos = 0;
let missingPhotos = 0;
let existingThumbnails = 0;
let missingThumbnails = 0;
for (const photo of photos) {
const photoPath = path.join(STORAGE_PATH, 'events/active', photo.path);
try {
await fs.access(photoPath);
existingPhotos++;
} catch {
missingPhotos++;
console.log(` ✗ Missing photo: ${photo.path}`);
}
if (photo.thumbnail_path) {
const thumbPath = path.join(STORAGE_PATH, photo.thumbnail_path.replace(/^\//, ''));
try {
await fs.access(thumbPath);
existingThumbnails++;
} catch {
missingThumbnails++;
console.log(` ✗ Missing thumbnail: ${photo.thumbnail_path}`);
}
}
}
console.log(`\nFile check summary:`);
console.log(` - Photos: ${existingPhotos} exist, ${missingPhotos} missing`);
console.log(` - Thumbnails: ${existingThumbnails} exist, ${missingThumbnails} missing`);
} else {
// List all event directories
try {
const activeDir = path.join(STORAGE_PATH, 'events/active');
const eventDirs = await fs.readdir(activeDir);
console.log(`Active event directories: ${eventDirs.length}`);
for (const dir of eventDirs.slice(0, 10)) {
console.log(` - ${dir}`);
}
if (eventDirs.length > 10) {
console.log(` ... and ${eventDirs.length - 10} more`);
}
} catch (error) {
console.log('Could not list event directories:', error.message);
}
}
}
// Parse command line arguments
const eventSlug = process.argv[2] || null;
// Run the script
checkStorageStructure(eventSlug).then(async () => {
await db.destroy();
console.log('\nStorage check complete');
}).catch(async error => {
console.error('Error:', error);
await db.destroy();
process.exit(1);
});
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env node
/**
* Script to clean up orphaned and temporary thumbnails
* Usage: node scripts/cleanup-thumbnails.js [--dry-run]
*/
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../src/database/db');
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
async function cleanupThumbnails(dryRun = false) {
console.log('Starting thumbnail cleanup...');
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}`);
console.log(`Mode: ${dryRun ? 'DRY RUN' : 'LIVE'}\n`);
try {
// Get all thumbnail files
const files = await fs.readdir(THUMBNAILS_DIR);
console.log(`Found ${files.length} files in thumbnails directory`);
// Get all valid thumbnail paths from database
const validThumbnails = await db('photos')
.whereNotNull('thumbnail_path')
.select('thumbnail_path');
const validPaths = new Set(
validThumbnails.map(t => path.basename(t.thumbnail_path))
);
console.log(`Found ${validPaths.size} valid thumbnails in database\n`);
let tempCount = 0;
let orphanedCount = 0;
let validCount = 0;
let deletedCount = 0;
for (const file of files) {
// Skip directories
const filePath = path.join(THUMBNAILS_DIR, file);
const stats = await fs.stat(filePath);
if (stats.isDirectory()) continue;
// Check if it's a temporary file
if (file.startsWith('thumb_temp_')) {
tempCount++;
console.log(`Temporary file: ${file}`);
if (!dryRun) {
try {
await fs.unlink(filePath);
deletedCount++;
} catch (error) {
console.error(` Failed to delete: ${error.message}`);
}
}
}
// Check if it's an orphaned thumbnail
else if (!validPaths.has(file)) {
orphanedCount++;
console.log(`Orphaned file: ${file}`);
if (!dryRun) {
try {
await fs.unlink(filePath);
deletedCount++;
} catch (error) {
console.error(` Failed to delete: ${error.message}`);
}
}
} else {
validCount++;
}
}
console.log('\n--- Summary ---');
console.log(`Total files: ${files.length}`);
console.log(`Valid thumbnails: ${validCount}`);
console.log(`Temporary files: ${tempCount}`);
console.log(`Orphaned files: ${orphanedCount}`);
if (!dryRun) {
console.log(`Deleted files: ${deletedCount}`);
} else {
console.log(`Files to be deleted: ${tempCount + orphanedCount}`);
}
} catch (error) {
console.error('Error during cleanup:', error);
process.exit(1);
}
}
// Parse command line arguments
const dryRun = process.argv.includes('--dry-run');
// Run the cleanup
cleanupThumbnails(dryRun).then(async () => {
await db.destroy();
console.log('\nCleanup complete');
}).catch(async error => {
console.error('Cleanup failed:', error);
await db.destroy();
process.exit(1);
});
+2 -1
View File
@@ -36,7 +36,8 @@ async function createTestEvent() {
await db('events').where('slug', eventData.slug).delete();
// Insert new event
const [eventId] = await db('events').insert(eventData);
const insertResult = await db('events').insert(eventData).returning('id');
const eventId = insertResult[0]?.id || insertResult[0];
console.log('Event created with ID:', eventId);
console.log('\nTest event created successfully!');
+132
View File
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* Script to diagnose thumbnail serving issues
* Usage: node scripts/diagnose-thumbnails.js <eventId>
*/
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../src/database/db');
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
async function diagnoseThumbnails(eventId) {
if (!eventId) {
console.error('Usage: node scripts/diagnose-thumbnails.js <eventId>');
process.exit(1);
}
console.log(`Diagnosing thumbnails for event ID: ${eventId}`);
console.log(`Storage path: ${STORAGE_PATH}`);
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}\n`);
try {
// Get event info
const event = await db('events').where('id', eventId).first();
if (!event) {
console.error(`Event not found with ID: ${eventId}`);
return;
}
console.log(`Event: ${event.event_name} (${event.slug})`);
console.log(`Active: ${event.is_active}, Archived: ${event.is_archived}\n`);
// Get photos for this event
const photos = await db('photos')
.where('event_id', eventId)
.select('id', 'filename', 'path', 'thumbnail_path');
console.log(`Found ${photos.length} photos in database\n`);
let missingThumbnails = 0;
let existingThumbnails = 0;
let pathIssues = [];
for (const photo of photos.slice(0, 10)) { // Check first 10 photos
console.log(`Photo ID ${photo.id}: ${photo.filename}`);
console.log(` Photo path: ${photo.path}`);
console.log(` Thumbnail path in DB: ${photo.thumbnail_path}`);
if (photo.thumbnail_path) {
// Expected thumbnail filename
const expectedThumbName = `thumb_${photo.filename}`;
const expectedThumbPath = path.join(THUMBNAILS_DIR, expectedThumbName);
// Check if thumbnail exists
try {
await fs.access(expectedThumbPath);
console.log(` ✓ Thumbnail exists at: ${expectedThumbName}`);
existingThumbnails++;
// Check if DB path matches expected path
const dbThumbName = path.basename(photo.thumbnail_path);
if (dbThumbName !== expectedThumbName) {
console.log(` ⚠ Path mismatch! DB has: ${dbThumbName}, Expected: ${expectedThumbName}`);
pathIssues.push({
photoId: photo.id,
dbPath: photo.thumbnail_path,
expectedPath: `thumbnails/${expectedThumbName}`
});
}
} catch {
console.log(` ✗ Thumbnail missing: ${expectedThumbName}`);
missingThumbnails++;
}
} else {
console.log(` ✗ No thumbnail path in database`);
missingThumbnails++;
}
console.log('');
}
console.log('--- Summary ---');
console.log(`Existing thumbnails: ${existingThumbnails}`);
console.log(`Missing thumbnails: ${missingThumbnails}`);
console.log(`Path issues: ${pathIssues.length}`);
if (pathIssues.length > 0) {
console.log('\n--- Path Issues ---');
console.log('The following photos have incorrect thumbnail paths in the database:');
for (const issue of pathIssues) {
console.log(`Photo ID ${issue.photoId}:`);
console.log(` Current: ${issue.dbPath}`);
console.log(` Should be: ${issue.expectedPath}`);
}
console.log('\nTo fix path issues, run:');
console.log(`UPDATE photos SET thumbnail_path = 'thumbnails/thumb_' || filename WHERE event_id = ${eventId};`);
}
// Check for any thumbnails in the directory that match this event
const files = await fs.readdir(THUMBNAILS_DIR);
const eventThumbnails = files.filter(f => {
// Try to match thumbnails for this event
for (const photo of photos) {
if (f === `thumb_${photo.filename}`) return true;
}
return false;
});
console.log(`\n--- Filesystem Check ---`);
console.log(`Found ${eventThumbnails.length} thumbnails in directory for this event`);
} catch (error) {
console.error('Error during diagnosis:', error);
process.exit(1);
}
}
// Parse command line arguments
const eventId = process.argv[2] ? parseInt(process.argv[2]) : null;
// Run the diagnosis
diagnoseThumbnails(eventId).then(async () => {
await db.destroy();
console.log('\nDiagnosis complete');
}).catch(async error => {
console.error('Diagnosis failed:', error);
await db.destroy();
process.exit(1);
});
+99
View File
@@ -0,0 +1,99 @@
#!/usr/bin/env node
/**
* Script to diagnose and fix email_queue schema issues
* This helps resolve the "column updated_at does not exist" error
*/
require('dotenv').config();
const { db } = require('../src/database/db');
async function checkAndFixEmailQueueSchema() {
console.log('Checking email_queue table schema...');
try {
// Get column information
const columns = await db('email_queue').columnInfo();
console.log('\nCurrent email_queue columns:', Object.keys(columns));
// Check for updated_at column
if (columns.updated_at) {
console.log('\n⚠️ Found unexpected updated_at column in email_queue table!');
console.log('This column should not exist and is causing errors.');
// Ask for confirmation before removing
console.log('\nRemoving updated_at column...');
await db.schema.table('email_queue', (table) => {
table.dropColumn('updated_at');
});
console.log('✅ Removed updated_at column from email_queue table');
} else {
console.log('✅ No updated_at column found (this is correct)');
}
// Verify required columns exist
const requiredColumns = [
'id', 'event_id', 'recipient_email', 'email_type',
'email_data', 'status', 'scheduled_at', 'sent_at',
'error_message', 'retry_count', 'created_at'
];
const missingColumns = requiredColumns.filter(col => !columns[col]);
if (missingColumns.length > 0) {
console.log('\n⚠️ Missing required columns:', missingColumns);
} else {
console.log('✅ All required columns are present');
}
// Check for any database triggers
if (process.env.DATABASE_CLIENT === 'pg') {
console.log('\nChecking for PostgreSQL triggers on email_queue...');
const triggers = await db.raw(`
SELECT trigger_name, event_manipulation, action_statement
FROM information_schema.triggers
WHERE event_object_table = 'email_queue'
AND trigger_schema = current_schema()
`);
if (triggers.rows && triggers.rows.length > 0) {
console.log('⚠️ Found triggers on email_queue table:');
triggers.rows.forEach(trigger => {
console.log(` - ${trigger.trigger_name} (${trigger.event_manipulation})`);
});
} else {
console.log('✅ No triggers found on email_queue table');
}
}
// Test update query
console.log('\nTesting update query...');
const testEmail = await db('email_queue')
.where('status', 'pending')
.first();
if (testEmail) {
try {
await db('email_queue')
.where('id', testEmail.id)
.update({
retry_count: testEmail.retry_count
});
console.log('✅ Update query works correctly');
} catch (error) {
console.log('❌ Update query failed:', error.message);
}
} else {
console.log('️ No pending emails to test with');
}
console.log('\nSchema check complete!');
} catch (error) {
console.error('Error checking schema:', error);
} finally {
await db.destroy();
}
}
// Run the check
checkAndFixEmailQueueSchema();
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env node
/**
* Script to regenerate missing thumbnails for photos in the database
* Usage: node scripts/regenerate-thumbnails.js [eventId]
*/
const path = require('path');
const fs = require('fs').promises;
const sharp = require('sharp');
const { db } = require('../src/database/db');
// Configuration
const THUMBNAIL_SIZE = 300;
const STORAGE_PATH = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const THUMBNAILS_DIR = path.join(STORAGE_PATH, 'thumbnails');
async function ensureDirectoryExists(dirPath) {
try {
await fs.access(dirPath);
} catch {
await fs.mkdir(dirPath, { recursive: true });
console.log(`Created directory: ${dirPath}`);
}
}
async function generateThumbnail(photoPath, thumbnailPath) {
try {
await sharp(photoPath)
.resize(THUMBNAIL_SIZE, THUMBNAIL_SIZE, {
fit: 'cover',
position: 'center'
})
.jpeg({ quality: 80 })
.toFile(thumbnailPath);
return true;
} catch (error) {
console.error(`Failed to generate thumbnail for ${photoPath}:`, error.message);
return false;
}
}
async function regenerateThumbnails(eventId = null) {
try {
console.log('Starting thumbnail regeneration...');
console.log(`Storage path: ${STORAGE_PATH}`);
console.log(`Thumbnails directory: ${THUMBNAILS_DIR}`);
// Ensure thumbnails directory exists
await ensureDirectoryExists(THUMBNAILS_DIR);
// Build query
let query = db('photos')
.join('events', 'photos.event_id', 'events.id')
.select(
'photos.id',
'photos.filename',
'photos.path',
'photos.thumbnail_path',
'events.slug as event_slug'
);
if (eventId) {
query = query.where('photos.event_id', eventId);
console.log(`Filtering for event ID: ${eventId}`);
}
const photos = await query;
console.log(`Found ${photos.length} photos to process`);
let successCount = 0;
let skipCount = 0;
let errorCount = 0;
for (const photo of photos) {
const photoPath = path.join(STORAGE_PATH, 'events/active', photo.path);
const thumbnailFilename = `thumb_${photo.filename}`;
const thumbnailPath = path.join(THUMBNAILS_DIR, thumbnailFilename);
try {
// Check if photo file exists
await fs.access(photoPath);
// Check if thumbnail already exists
try {
await fs.access(thumbnailPath);
console.log(`Thumbnail already exists for ${photo.filename}, skipping...`);
skipCount++;
continue;
} catch {
// Thumbnail doesn't exist, generate it
}
console.log(`Generating thumbnail for ${photo.filename}...`);
const success = await generateThumbnail(photoPath, thumbnailPath);
if (success) {
// Update database with thumbnail path
await db('photos')
.where('id', photo.id)
.update({
thumbnail_path: `thumbnails/${thumbnailFilename}`
});
successCount++;
console.log(`✓ Generated thumbnail for ${photo.filename}`);
} else {
errorCount++;
}
} catch (error) {
console.error(`✗ Photo file not found: ${photoPath}`);
errorCount++;
}
}
console.log('\nThumbnail regeneration complete!');
console.log(`- Successfully generated: ${successCount}`);
console.log(`- Skipped (already exist): ${skipCount}`);
console.log(`- Errors: ${errorCount}`);
console.log(`- Total processed: ${photos.length}`);
} catch (error) {
console.error('Error during thumbnail regeneration:', error);
process.exit(1);
} finally {
await db.destroy();
}
}
// Parse command line arguments
const eventId = process.argv[2] ? parseInt(process.argv[2]) : null;
// Run the script
regenerateThumbnails(eventId).then(() => {
console.log('Script completed successfully');
process.exit(0);
}).catch(error => {
console.error('Script failed:', error);
process.exit(1);
});
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env node
const { db } = require('../src/database/db');
const {
initializeTransporter,
processEmailQueue,
testEmailConnection
} = require('../src/services/emailProcessor');
const winston = require('winston');
// Create a simple console logger
const logger = winston.createLogger({
format: winston.format.simple(),
transports: [new winston.transports.Console()]
});
async function runEmailProcessor(runOnce = false) {
try {
logger.info('=== Starting Email Processor ===\n');
// Initialize transporter
logger.info('Initializing email transporter...');
await initializeTransporter();
// Test connection
logger.info('Testing email connection...');
const connectionOk = await testEmailConnection();
if (!connectionOk) {
logger.error('Email connection test failed! Check your SMTP configuration.');
logger.info('\nRequired environment variables:');
logger.info('- SMTP_HOST');
logger.info('- SMTP_PORT');
logger.info('- SMTP_USER');
logger.info('- SMTP_PASS');
logger.info('- SMTP_FROM');
process.exit(1);
}
logger.info('Email connection test successful!\n');
if (runOnce) {
// Process queue once
logger.info('Processing email queue once...');
await processEmailQueue();
logger.info('Email processing complete');
// Show final status
const pendingCount = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
.count('* as count')
.first();
logger.info(`\nEmails still pending: ${pendingCount.count}`);
await db.destroy();
process.exit(0);
} else {
// Run continuously
logger.info('Starting continuous email processor...');
logger.info('Processing emails every 60 seconds. Press Ctrl+C to stop.\n');
// Process immediately
await processEmailQueue();
// Then every minute
setInterval(async () => {
try {
await processEmailQueue();
} catch (error) {
logger.error('Error processing email queue:', error);
}
}, 60000);
}
} catch (error) {
logger.error('Fatal error:', error);
await db.destroy();
process.exit(1);
}
}
// Handle graceful shutdown
process.on('SIGINT', async () => {
logger.info('\n\nShutting down email processor...');
await db.destroy();
process.exit(0);
});
// Check command line arguments
const args = process.argv.slice(2);
const runOnce = args.includes('--once') || args.includes('-o');
if (args.includes('--help') || args.includes('-h')) {
console.log(`
Email Processor Runner
Usage: node run-email-processor.js [options]
Options:
--once, -o Process the email queue once and exit
--help, -h Show this help message
By default, the processor runs continuously, checking for emails every 60 seconds.
`);
process.exit(0);
}
// Run the processor
runEmailProcessor(runOnce);
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* Script to test photo authentication
* Usage: node scripts/test-photo-auth.js <jwt-token>
*/
const axios = require('axios');
async function testPhotoAuth(token) {
if (!token) {
console.error('Usage: node scripts/test-photo-auth.js <jwt-token>');
console.error('\nTo get a token, login to a gallery and check localStorage for gallery_token_<slug>');
process.exit(1);
}
const baseUrl = process.env.API_URL || 'http://localhost:3001';
console.log(`Testing photo authentication with token: ${token.substring(0, 20)}...`);
console.log(`Base URL: ${baseUrl}\n`);
// Test URLs
const tests = [
{
name: 'Thumbnail via static route',
url: `${baseUrl}/thumbnails/thumb_Test_Gallery_uncategorized_5210.jpg`,
headers: { 'Authorization': `Bearer ${token}` }
},
{
name: 'Photo via static route',
url: `${baseUrl}/photos/wedding-test-gallery-2025-07-14-1/Test_Gallery_uncategorized_5210.jpg`,
headers: { 'Authorization': `Bearer ${token}` }
},
{
name: 'Gallery photos API',
url: `${baseUrl}/api/gallery/wedding-test-gallery-2025-07-14-1/photos`,
headers: { 'Authorization': `Bearer ${token}` }
}
];
for (const test of tests) {
console.log(`Testing: ${test.name}`);
console.log(`URL: ${test.url}`);
try {
const response = await axios.get(test.url, {
headers: test.headers,
validateStatus: () => true // Don't throw on any status
});
console.log(`Status: ${response.status}`);
console.log(`Headers:`, response.headers['content-type']);
if (response.status === 200) {
if (test.name.includes('API')) {
console.log(`Photos count: ${response.data.photos?.length || 0}`);
} else {
console.log(`Content length: ${response.headers['content-length']} bytes`);
}
} else {
console.log(`Error:`, response.data);
}
} catch (error) {
console.log(`Network error:`, error.message);
}
console.log('---\n');
}
// Decode token to show info
try {
const parts = token.split('.');
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString());
console.log('Token payload:', payload);
} catch (error) {
console.log('Failed to decode token');
}
}
// Get token from command line
const token = process.argv[2];
testPhotoAuth(token).catch(error => {
console.error('Test failed:', error);
process.exit(1);
});
+9 -6
View File
@@ -125,9 +125,9 @@ const authLimiter = rateLimit({
app.use('/api/', limiter);
app.use('/api/auth', authLimiter);
// Body parsing middleware
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Body parsing middleware with increased limits for large uploads
app.use(express.json({ limit: '100mb' }));
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
// Maintenance mode middleware - add after body parsing but before routes
app.use(maintenanceMiddleware);
@@ -146,14 +146,17 @@ const setCorsHeaders = (req, res, next) => {
// Import secure static middleware
const secureStatic = require('./src/middleware/secureStatic');
// Get storage path from environment or use default
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../storage');
// Static file serving for photos (protected)
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(__dirname, 'storage/events/active')));
app.use('/photos', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'events/active')));
// Static file serving for thumbnails (protected)
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(__dirname, 'storage/thumbnails')));
app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, secureStatic(path.join(storagePath, 'thumbnails')));
// Static file serving for uploads (public - logos, favicons)
app.use('/uploads', setCorsHeaders, secureStatic(path.join(__dirname, 'storage/uploads')));
app.use('/uploads', setCorsHeaders, secureStatic(path.join(storagePath, 'uploads')));
// Health check endpoint
app.get('/health', async (req, res) => {
+83
View File
@@ -0,0 +1,83 @@
const { formatBoolean, isPostgreSQL, addDays, formatDateForDB, insertAndGetId } = require('../utils/dbCompat');
describe('Database Compatibility', () => {
// Save original env
const originalEnv = process.env.DATABASE_CLIENT;
afterEach(() => {
// Restore original env after each test
if (originalEnv) {
process.env.DATABASE_CLIENT = originalEnv;
} else {
delete process.env.DATABASE_CLIENT;
}
});
describe('formatBoolean', () => {
test('should format boolean values correctly', () => {
// Mock for SQLite
process.env.DATABASE_CLIENT = 'sqlite3';
expect(formatBoolean(true)).toBe(1);
expect(formatBoolean(false)).toBe(0);
// Mock for PostgreSQL
process.env.DATABASE_CLIENT = 'pg';
expect(formatBoolean(true)).toBe(true);
expect(formatBoolean(false)).toBe(false);
// Default (no env var) should be SQLite
delete process.env.DATABASE_CLIENT;
expect(formatBoolean(true)).toBe(1);
expect(formatBoolean(false)).toBe(0);
});
});
describe('isPostgreSQL', () => {
test('should detect PostgreSQL correctly', () => {
process.env.DATABASE_CLIENT = 'pg';
expect(isPostgreSQL()).toBe(true);
process.env.DATABASE_CLIENT = 'sqlite3';
expect(isPostgreSQL()).toBe(false);
delete process.env.DATABASE_CLIENT;
expect(isPostgreSQL()).toBe(false); // Default to SQLite
});
});
describe('formatDateForDB', () => {
test('should format dates as ISO strings', () => {
const date = new Date('2024-01-15T10:30:00Z');
expect(formatDateForDB(date)).toBe('2024-01-15T10:30:00.000Z');
});
});
describe('addDays', () => {
test('should add days correctly', () => {
const date = new Date('2024-01-15');
const result = addDays(date, 30);
expect(result.toISOString().split('T')[0]).toBe('2024-02-14');
const negativeResult = addDays(date, -7);
expect(negativeResult.toISOString().split('T')[0]).toBe('2024-01-08');
});
});
describe('insertAndGetId', () => {
test('should handle PostgreSQL result format', async () => {
const mockQuery = {
returning: jest.fn().mockResolvedValue([{ id: 123 }])
};
const result = await insertAndGetId(mockQuery);
expect(result).toBe(123);
});
test('should handle SQLite result format', async () => {
const mockQuery = {
returning: jest.fn().mockResolvedValue([456])
};
const result = await insertAndGetId(mockQuery);
expect(result).toBe(456);
});
});
});
+2 -1
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { isTokenRevoked } = require('../utils/tokenRevocation');
const logger = require('../utils/logger');
@@ -57,7 +58,7 @@ async function adminAuth(req, res, next) {
// Check if admin still exists and is active
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: true })
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
+3 -2
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
/**
@@ -50,7 +51,7 @@ async function adminAuth(req, res, next) {
// Check if admin still exists and is active
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: true })
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
@@ -165,7 +166,7 @@ async function photoAuth(req, res, next) {
// Allow both admin and gallery tokens
if (decoded.type === 'admin') {
const admin = await db('admin_users')
.where({ id: decoded.id, is_active: true })
.where({ id: decoded.id, is_active: formatBoolean(true) })
.first();
if (!admin) {
+2 -1
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
async function adminAuth(req, res, next) {
try {
@@ -9,7 +10,7 @@ async function adminAuth(req, res, next) {
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const admin = await db('admin_users').where({ id: decoded.id, is_active: true }).first();
const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first();
if (!admin) {
return res.status(401).json({ error: 'Invalid token' });
+8 -1
View File
@@ -1,5 +1,6 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
// Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) {
@@ -10,7 +11,13 @@ async function verifyGalleryAccess(req, res, next) {
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const event = await db('events').where({ id: decoded.eventId, is_active: true }).first();
const event = await db('events')
.where({
id: decoded.eventId,
is_active: formatBoolean(true),
is_archived: formatBoolean(false)
})
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
+27 -15
View File
@@ -1,12 +1,15 @@
const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
async function photoAuth(req, res, next) {
try {
// Extract event slug from the path
let eventSlug;
console.log('PhotoAuth middleware - path:', req.path);
// For thumbnails, we need to parse the filename to get the event info
if (req.path.startsWith('/thumb_')) {
// For now, we'll rely on JWT token for thumbnail access
@@ -25,9 +28,22 @@ async function photoAuth(req, res, next) {
// Check if it's a gallery token
if (decoded.type === 'gallery') {
// For thumbnails, we accept any valid gallery token
// For thumbnails, we need to verify the token is for a valid event
if (!eventSlug) {
const event = await db('events').where({ slug: decoded.eventSlug, is_active: true }).first();
// Extract event ID from the decoded token
if (decoded.eventId) {
const event = await db('events')
.where({ id: decoded.eventId, is_active: formatBoolean(true) })
.first();
if (event) {
req.event = event;
return next();
}
}
// Fallback to slug
const event = await db('events')
.where({ slug: decoded.eventSlug, is_active: formatBoolean(true) })
.first();
if (event) {
req.event = event;
return next();
@@ -35,7 +51,9 @@ async function photoAuth(req, res, next) {
}
// For regular photos, check if token matches the event
else if (decoded.eventSlug === eventSlug) {
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
const event = await db('events')
.where({ slug: eventSlug, is_active: formatBoolean(true) })
.first();
if (event) {
req.event = event;
return next();
@@ -45,18 +63,12 @@ async function photoAuth(req, res, next) {
// Check if it's an admin token (admins can view all photos)
if (decoded.type === 'admin') {
if (!eventSlug) {
// For thumbnails with admin token, allow access
return next();
}
const event = await db('events').where({ slug: eventSlug }).first();
if (event) {
req.event = event;
return next();
}
// For both thumbnails and photos with admin token, allow access
return next();
}
} catch (err) {
// Token invalid, fall through to password check
console.error('JWT verification failed:', err.message);
}
}
@@ -67,12 +79,12 @@ async function photoAuth(req, res, next) {
return res.status(401).json({ error: 'Authentication required' });
}
// If no eventSlug (thumbnails), we require JWT token
if (!eventSlug) {
// If no eventSlug (thumbnails), and we don't have valid auth yet, deny access
if (!eventSlug && !password) {
return res.status(401).json({ error: 'Authentication required for thumbnails' });
}
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found' });
}
+33 -10
View File
@@ -2,6 +2,7 @@ const express = require('express');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const archiver = require('archiver');
const AdmZip = require('adm-zip');
@@ -16,7 +17,7 @@ router.get('/', adminAuth, async (req, res) => {
// Get total count
const totalCount = await db('events')
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.count('id as count')
.first();
@@ -28,7 +29,7 @@ router.get('/', adminAuth, async (req, res) => {
db.raw('SUM(photos.size_bytes) as total_size')
)
.leftJoin('photos', 'events.id', 'photos.event_id')
.where('events.is_archived', true)
.where('events.is_archived', formatBoolean(true))
.groupBy('events.id')
.orderBy('events.archived_at', 'desc')
.limit(limit)
@@ -84,7 +85,7 @@ router.get('/:id', adminAuth, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.first();
if (!archive) {
@@ -140,7 +141,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.first();
if (!archive) {
@@ -211,12 +212,14 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
categoriesMap.set(categoryName, existingCategory.id);
} else {
// Create the category if it doesn't exist
const [newCategoryId] = await db('photo_categories').insert({
const insertResult = await db('photo_categories').insert({
event_id: archive.id,
name: categoryName,
slug: categoryName.toLowerCase().replace(/[^a-z0-9]/g, '-'),
created_at: new Date()
});
}).returning('id');
const newCategoryId = insertResult[0]?.id || insertResult[0];
categoriesMap.set(categoryName, newCategoryId);
}
}
@@ -266,6 +269,9 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
}
// Update event status
const thirtyDaysFromNow = new Date();
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
await db('events')
.where('id', req.params.id)
.update({
@@ -273,7 +279,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
is_active: true,
archive_path: null,
archived_at: null,
expires_at: db.raw("datetime('now', '+30 days')") // Reset expiration
expires_at: thirtyDaysFromNow.toISOString() // Reset expiration - works on both DBs
});
// Log activity
@@ -298,7 +304,7 @@ router.get('/:id/download', adminAuth, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.first();
if (!archive) {
@@ -347,7 +353,7 @@ router.delete('/:id', adminAuth, async (req, res) => {
try {
const archive = await db('events')
.where('id', req.params.id)
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.first();
if (!archive) {
@@ -357,12 +363,29 @@ router.delete('/:id', adminAuth, async (req, res) => {
// Delete archive file if exists
if (archive.archive_path) {
try {
await fs.unlink(archive.archive_path);
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const fullArchivePath = path.join(storagePath, archive.archive_path);
await fs.unlink(fullArchivePath);
} catch (error) {
console.error('Failed to delete archive file:', error);
}
}
// Delete thumbnails for this event
const photos = await db('photos').where('event_id', req.params.id).select('thumbnail_path');
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
for (const photo of photos) {
if (photo.thumbnail_path) {
try {
const thumbPath = path.join(storagePath, photo.thumbnail_path.replace(/^\//, ''));
await fs.unlink(thumbPath);
} catch (error) {
// Ignore errors - thumbnail might already be deleted
}
}
}
// Delete from database (cascade will delete photos and logs)
await db('events').where('id', req.params.id).delete();
+8 -5
View File
@@ -1,6 +1,7 @@
const express = require('express');
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const router = express.Router();
@@ -8,7 +9,7 @@ const router = express.Router();
router.get('/global', adminAuth, async (req, res) => {
try {
const categories = await db('photo_categories')
.where('is_global', true)
.where('is_global', formatBoolean(true))
.orderBy('name', 'asc');
res.json(categories);
@@ -25,7 +26,7 @@ router.get('/event/:eventId', adminAuth, async (req, res) => {
const categories = await db('photo_categories')
.where(function() {
this.where('is_global', true)
this.where('is_global', formatBoolean(true))
.orWhere('event_id', eventId);
})
.orderBy('is_global', 'desc')
@@ -65,7 +66,7 @@ router.post('/', adminAuth, [
.where('slug', categorySlug)
.where(function() {
if (is_global) {
this.where('is_global', true);
this.where('is_global', formatBoolean(true));
} else {
this.where('event_id', event_id);
}
@@ -77,12 +78,14 @@ router.post('/', adminAuth, [
}
// Create category
const [categoryId] = await db('photo_categories').insert({
const insertResult = await db('photo_categories').insert({
name,
slug: categorySlug,
is_global,
event_id: is_global ? null : event_id
});
}).returning('id');
const categoryId = insertResult[0]?.id || insertResult[0];
const category = await db('photo_categories').where('id', categoryId).first();
+6 -5
View File
@@ -2,6 +2,7 @@ const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const { sanitizeDays, addDateRangeCondition } = require('../utils/sqlSecurity');
const { formatBoolean } = require('../utils/dbCompat');
const router = express.Router();
// Get dashboard statistics
@@ -9,8 +10,8 @@ router.get('/stats', adminAuth, async (req, res) => {
try {
// Get active events count
const activeEvents = await db('events')
.where('is_active', true)
.where('is_archived', false)
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.count('id as count')
.first();
@@ -20,8 +21,8 @@ router.get('/stats', adminAuth, async (req, res) => {
const now = new Date();
const expiringEvents = await db('events')
.where('is_active', true)
.where('is_archived', false)
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
.where('expires_at', '>', now.toISOString())
.count('id as count')
@@ -56,7 +57,7 @@ router.get('/stats', adminAuth, async (req, res) => {
// Get archived events count
const archivedEvents = await db('events')
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.count('id as count')
.first();
+5 -2
View File
@@ -83,7 +83,7 @@ router.post('/', adminAuth, [
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
// Insert into database
const [eventId] = await db('events').insert({
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
@@ -99,7 +99,10 @@ router.post('/', adminAuth, [
created_at: new Date().toISOString(),
allow_user_uploads,
upload_category_id
});
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0];
// Log activity
await logActivity('event_created',
+82 -16
View File
@@ -11,6 +11,7 @@ const { archiveEvent } = require('../services/archiveService');
const { escapeLikePattern } = require('../utils/sqlSecurity');
const { formatDate } = require('../utils/dateFormatter');
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { formatBoolean } = require('../utils/dbCompat');
// Create new event
router.post('/', adminAuth, [
@@ -65,7 +66,12 @@ router.post('/', adminAuth, [
}
// Generate unique slug
const baseSlug = `${event_type}-${event_name.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${event_date}`;
const processedEventName = event_name
.toLowerCase()
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
.replace(/-+/g, '-') // Replace multiple dashes with single dash
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
const baseSlug = `${event_type}-${processedEventName}-${event_date}`;
let slug = baseSlug;
let counter = 1;
@@ -92,7 +98,7 @@ router.post('/', adminAuth, [
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
// Insert into database
const [eventId] = await db('events').insert({
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
@@ -108,7 +114,10 @@ router.post('/', adminAuth, [
created_at: new Date().toISOString(),
allow_user_uploads,
upload_category_id
});
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0];
// Log activity
await logActivity('event_created',
@@ -133,7 +142,9 @@ router.post('/', adminAuth, [
gallery_password: password,
expiry_date: await formatDate(expires_at, emailLang),
welcome_message: welcome_message || ''
})
}),
status: 'pending',
created_at: new Date()
// scheduled_at will use default value
});
@@ -178,17 +189,17 @@ router.get('/', adminAuth, async (req, res) => {
// Apply status filter
if (status === 'active') {
query = query.where('is_active', true).where('is_archived', false);
query = query.where('is_active', formatBoolean(true)).where('is_archived', formatBoolean(false));
} else if (status === 'archived') {
query = query.where('is_archived', true);
query = query.where('is_archived', formatBoolean(true));
} else if (status === 'inactive') {
query = query.where('is_active', false).where('is_archived', false);
query = query.where('is_active', formatBoolean(false)).where('is_archived', formatBoolean(false));
} else if (status === 'expiring') {
const sevenDaysFromNow = new Date();
sevenDaysFromNow.setDate(sevenDaysFromNow.getDate() + 7);
query = query
.where('is_active', true)
.where('is_archived', false)
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.where('expires_at', '<=', sevenDaysFromNow.toISOString())
.where('expires_at', '>', new Date().toISOString());
}
@@ -382,13 +393,56 @@ router.delete('/:id', adminAuth, async (req, res) => {
return res.status(404).json({ error: 'Event not found' });
}
// Delete associated photos
await db('photos').where('event_id', id).del();
// Start a transaction to ensure all deletions succeed or fail together
await db.transaction(async (trx) => {
// 1. Delete activity logs (audit trail)
await trx('activity_logs').where('event_id', id).del();
// Delete event
await db('events').where('id', id).del();
// 2. Delete access logs
await trx('access_logs').where('event_id', id).del();
// Log activity
// 3. Delete email queue entries
await trx('email_queue').where('event_id', id).del();
// 4. Delete photos (this will also handle hero_photo_id foreign key)
await trx('photos').where('event_id', id).del();
// 5. Delete categories (photo_categories has CASCADE delete for event_id)
await trx('photo_categories').where('event_id', id).del();
// 6. Finally delete the event
await trx('events').where('id', id).del();
// Delete event folder from storage if it exists
if (event.folder_path) {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const eventFolderPath = path.join(storagePath, 'events', 'active', event.folder_path);
try {
const fsPromises = require('fs').promises;
await fsPromises.rm(eventFolderPath, { recursive: true, force: true });
} catch (err) {
console.error('Failed to delete event folder:', err);
// Don't fail the transaction if folder deletion fails
}
}
// Delete archive if exists
if (event.archive_path) {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const archivePath = path.join(storagePath, event.archive_path);
try {
const fsPromises = require('fs').promises;
await fsPromises.unlink(archivePath);
} catch (err) {
console.error('Failed to delete archive file:', err);
// Don't fail the transaction if file deletion fails
}
}
});
// Log activity (outside transaction)
await logActivity('event_deleted',
{ event_name: event.event_name },
null,
@@ -398,7 +452,19 @@ router.delete('/:id', adminAuth, async (req, res) => {
res.json({ message: 'Event deleted successfully' });
} catch (error) {
console.error('Error deleting event:', error);
res.status(500).json({ error: 'Failed to delete event' });
// Provide more specific error messages
if (error.message && error.message.includes('foreign key constraint')) {
res.status(500).json({
error: 'Cannot delete event due to existing references. Please contact support.',
details: error.message
});
} else {
res.status(500).json({
error: 'Failed to delete event',
details: process.env.NODE_ENV === 'development' ? error.message : undefined
});
}
}
});
@@ -549,7 +615,7 @@ router.post('/bulk-archive', adminAuth, [
// Get all events to archive
const events = await db('events')
.whereIn('id', eventIds)
.where('is_archived', false);
.where('is_archived', formatBoolean(false));
if (events.length === 0) {
return res.status(400).json({ error: 'No valid events found to archive' });
+143 -72
View File
@@ -61,7 +61,10 @@ const { validateFileType } = require('../utils/fileSecurityUtils');
const upload = multer({
storage: storage,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB limit
fileSize: 50 * 1024 * 1024, // 50MB limit per file
files: 500, // Maximum 500 files
// Set a reasonable field size limit to prevent memory issues
fieldSize: 10 * 1024 * 1024, // 10MB for non-file fields
},
fileFilter: (req, file, cb) => {
// Accept images only with proper validation
@@ -85,13 +88,17 @@ const validateUploadContent = createFileUploadValidator({
});
// Upload photos for an event
// Increased limit to 500 files, but recommend chunked uploads for better performance
router.post('/:eventId/upload', adminAuth, (req, res, next) => {
upload.array('photos', 20)(req, res, (err) => {
upload.array('photos', 500)(req, res, (err) => {
if (err) {
console.error('Multer error:', err);
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({ error: 'File too large. Maximum size is 50MB.' });
return res.status(400).json({ error: 'File too large. Maximum size is 50MB per file.' });
}
if (err.code === 'LIMIT_FILE_COUNT') {
return res.status(400).json({ error: 'Too many files. Maximum 500 files per upload.' });
}
return res.status(400).json({ error: `Upload error: ${err.message}` });
}
@@ -136,90 +143,122 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
}
const uploadedPhotos = [];
const errors = [];
// Process each uploaded file
for (const file of req.files) {
let trx;
// Process files in batches to optimize database operations
const BATCH_SIZE = 10; // Process 10 files at a time for database operations
for (let i = 0; i < req.files.length; i += BATCH_SIZE) {
const batch = req.files.slice(i, i + BATCH_SIZE);
// Start a single transaction for the batch
const trx = await db.transaction();
try {
// Start transaction for atomic counter update
trx = await db.transaction();
// Get and increment the counter for this category
let counter = 1;
// Get initial counter for this batch
let batchCounter = 1;
if (category) {
// Lock the category row and get current counter
const categoryData = await trx('photo_categories')
.where({ id: parsedCategoryId })
.forUpdate()
.first();
counter = (categoryData.photo_counter || 0) + 1;
// Update counter
await trx('photo_categories')
.where({ id: parsedCategoryId })
.update({ photo_counter: counter });
batchCounter = (categoryData.photo_counter || 0) + 1;
} else {
// For uncategorized photos, count existing uncategorized photos
const uncategorizedCount = await trx('photos')
.where({ event_id: eventId })
.whereNull('category_id')
.count('id as count')
.first();
counter = (uncategorizedCount.count || 0) + 1;
batchCounter = (uncategorizedCount.count || 0) + 1;
}
// Generate new filename
const extension = path.extname(file.originalname);
const newFilename = generatePhotoFilename(
event.event_name,
category ? category.name : 'uncategorized',
counter,
extension
);
const batchPhotos = [];
// Rename the file
const oldPath = file.path;
const newPath = path.join(path.dirname(oldPath), newFilename);
await fs.rename(oldPath, newPath);
for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) {
const file = batch[fileIndex];
const counter = batchCounter + fileIndex;
try {
// Generate new filename
const extension = path.extname(file.originalname);
const newFilename = generatePhotoFilename(
event.event_name,
category ? category.name : 'uncategorized',
counter,
extension
);
// Rename the file
const oldPath = file.path;
const newPath = path.join(path.dirname(oldPath), newFilename);
await fs.rename(oldPath, newPath);
// Update file object
file.filename = newFilename;
file.path = newPath;
// Generate thumbnail with new filename
const thumbnailPath = await generateThumbnail(file.path);
// Calculate relative paths
const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path);
const relativeThumbPath = thumbnailPath;
// Prepare photo data for batch insert
batchPhotos.push({
event_id: eventId,
filename: file.filename,
path: relativePath,
thumbnail_path: relativeThumbPath,
category_id: parsedCategoryId || null,
type: 'individual',
size_bytes: file.size
});
} catch (error) {
console.error(`Error processing file ${file.originalname}:`, error);
errors.push({ filename: file.originalname, error: error.message });
// Delete the file if it was partially processed
if (file.path) {
try { await fs.unlink(file.path); } catch (e) {}
}
}
}
// Update file object
file.filename = newFilename;
file.path = newPath;
// Batch insert all photos from this batch
if (batchPhotos.length > 0) {
const insertedIds = await trx('photos').insert(batchPhotos).returning('id');
// Update category counter if needed
if (category) {
await trx('photo_categories')
.where({ id: parsedCategoryId })
.update({ photo_counter: batchCounter + batchPhotos.length - 1 });
}
// Add to uploaded photos array
batchPhotos.forEach((photo, index) => {
uploadedPhotos.push({
id: insertedIds[index]?.id || insertedIds[index],
filename: photo.filename,
size: photo.size_bytes,
category_id: photo.category_id
});
});
}
// Generate thumbnail with new filename
const thumbnailPath = await generateThumbnail(file.path);
// Calculate relative paths
const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path);
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
// Add to database
const [photoId] = await trx('photos').insert({
event_id: eventId,
filename: file.filename,
path: relativePath,
thumbnail_path: relativeThumbPath,
category_id: parsedCategoryId || null,
type: 'individual', // Keep for backwards compatibility
size_bytes: file.size
});
// Commit transaction
// Commit the batch transaction
await trx.commit();
uploadedPhotos.push({
id: photoId,
filename: file.filename,
size: file.size,
category_id: parsedCategoryId || null
});
} catch (error) {
console.error(`Error processing file ${file.filename}:`, error);
if (trx) await trx.rollback();
// Continue with other files
console.error(`Error processing batch starting at index ${i}:`, error);
await trx.rollback();
// Try to clean up files from failed batch
for (const file of batch) {
if (file.path) {
try { await fs.unlink(file.path); } catch (e) {}
}
}
}
}
@@ -230,10 +269,22 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
// Prepare response
const response = {
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
photos: uploadedPhotos
});
photos: uploadedPhotos,
totalFiles: req.files.length,
successCount: uploadedPhotos.length,
failureCount: errors.length
};
// Include error details if any files failed
if (errors.length > 0) {
response.errors = errors;
response.message = `Uploaded ${uploadedPhotos.length} of ${req.files.length} photos. ${errors.length} failed.`;
}
res.json(response);
} catch (error) {
console.error('Error uploading photos:', error);
res.status(500).json({ error: 'Failed to upload photos' });
@@ -501,8 +552,8 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
url: `/api/admin/events/${eventId}/photo/${photo.id}`,
thumbnail_url: photo.thumbnail_path ? `/api/admin/events/${eventId}/thumbnail/${photo.id}` : null,
url: `/admin/events/${eventId}/photo/${photo.id}`,
thumbnail_url: photo.thumbnail_path ? `/admin/events/${eventId}/thumbnail/${photo.id}` : null,
type: photo.type,
category_id: photo.category_id,
category_name: photo.category_name,
@@ -595,4 +646,24 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
}
});
// Debug endpoint to check photo existence
router.get('/:eventId/debug', adminAuth, async (req, res) => {
try {
const { eventId } = req.params;
const event = await db('events').where({ id: eventId }).first();
const photoCount = await db('photos').where({ event_id: eventId }).count('id as count').first();
const photos = await db('photos').where({ event_id: eventId }).limit(5);
res.json({
event: event || 'Not found',
photoCount: photoCount.count,
samplePhotos: photos,
storagePath: getStoragePath()
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;
+11 -6
View File
@@ -4,6 +4,7 @@ const path = require('path');
const fs = require('fs').promises;
const { body, validationResult } = require('express-validator');
const { db, logActivity } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth');
const { clearMaintenanceCache } = require('../middleware/maintenance');
const router = express.Router();
@@ -513,17 +514,21 @@ router.get('/storage/info', adminAuth, async (req, res) => {
// Get archive storage
const archives = await db('events')
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.whereNotNull('archive_path')
.select('archive_path');
let archiveStorage = 0;
for (const archive of archives) {
try {
const stats = await fs.stat(archive.archive_path);
archiveStorage += stats.size;
} catch (error) {
console.error('Archive file not found:', archive.archive_path);
if (archive.archive_path) {
try {
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const fullArchivePath = path.join(storagePath, archive.archive_path);
const stats = await fs.stat(fullArchivePath);
archiveStorage += stats.size;
} catch (error) {
console.error('Archive file not found:', archive.archive_path, error.message);
}
}
}
+32
View File
@@ -4,6 +4,7 @@ const { adminAuth } = require('../middleware/auth-enhanced-v2');
const fs = require('fs').promises;
const path = require('path');
const os = require('os');
const { formatBoolean } = require('../utils/dbCompat');
const router = express.Router();
// Get system version
@@ -75,6 +76,32 @@ router.get('/status', adminAuth, async (req, res) => {
// Activity logs count
const [activityCount] = await db('activity_logs').count('* as count');
// Storage info
const [{ totalPhotoStorage }] = await db('photos')
.sum('size_bytes as totalPhotoStorage');
const archives = await db('events')
.where('is_archived', formatBoolean(true))
.whereNotNull('archive_path')
.select('archive_path');
let archiveStorage = 0;
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
for (const archive of archives) {
if (archive.archive_path) {
try {
const fullArchivePath = path.join(storagePath, archive.archive_path);
const stats = await fs.stat(fullArchivePath);
archiveStorage += stats.size;
} catch (error) {
console.error('Archive file not found:', archive.archive_path);
}
}
}
const totalStorage = (parseInt(totalPhotoStorage) || 0) + archiveStorage;
// System info
const systemInfo = {
platform: os.platform(),
@@ -105,6 +132,11 @@ router.get('/status', adminAuth, async (req, res) => {
activityLogs: activityCount.count
}
},
storage: {
totalUsed: totalStorage,
photoStorage: parseInt(totalPhotoStorage) || 0,
archiveStorage: archiveStorage
},
emailQueue: {
pending: pendingEmails.count,
sent: sentEmails.count,
+2 -1
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
const {
trackFailedAttempt,
@@ -248,7 +249,7 @@ router.post('/gallery/verify', [
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first();
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
if (!event) {
// Don't reveal if gallery exists
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
+2 -1
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
const {
trackFailedAttempt,
@@ -167,7 +168,7 @@ router.post('/gallery/verify', [
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first();
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
if (!event) {
// Don't reveal if gallery exists
await trackFailedAttempt(`gallery:${slug}`, ipAddress, userAgent);
+4 -2
View File
@@ -3,6 +3,7 @@ const bcrypt = require('bcrypt');
const jwt = require('jsonwebtoken');
const { body, validationResult } = require('express-validator');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyRecaptcha } = require('../services/recaptcha');
const router = express.Router();
@@ -76,7 +77,7 @@ router.post('/gallery/verify', [
return res.status(400).json({ error: 'reCAPTCHA verification failed' });
}
const event = await db('events').where({ slug, is_active: true, is_archived: false }).first();
const event = await db('events').where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }).first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
@@ -118,7 +119,8 @@ router.post('/gallery/verify', [
color_theme: event.color_theme,
expires_at: event.expires_at,
allow_user_uploads: event.allow_user_uploads,
upload_category_id: event.upload_category_id
upload_category_id: event.upload_category_id,
hero_photo_id: event.hero_photo_id
}
});
} catch (error) {
+10 -6
View File
@@ -3,6 +3,7 @@ const { body, validationResult } = require('express-validator');
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const fs = require('fs').promises;
const path = require('path');
@@ -64,7 +65,7 @@ router.post('/', adminAuth, [
await fs.mkdir(path.join(eventPath, 'individual'), { recursive: true });
// Insert into database
const [eventId] = await db('events').insert({
const insertResult = await db('events').insert({
slug,
event_type,
event_name,
@@ -76,7 +77,10 @@ router.post('/', adminAuth, [
color_theme,
share_link: shareLink,
expires_at
});
}).returning('id');
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
const eventId = insertResult[0]?.id || insertResult[0];
// Queue creation email
const { queueEmail } = require('../services/emailProcessor');
@@ -110,9 +114,9 @@ router.get('/', adminAuth, async (req, res) => {
let query = db('events').select('*');
if (status === 'active') {
query = query.where('is_active', true);
query = query.where('is_active', formatBoolean(true));
} else if (status === 'archived') {
query = query.where('is_archived', true);
query = query.where('is_archived', formatBoolean(true));
}
const events = await query.orderBy('created_at', 'desc');
@@ -159,7 +163,7 @@ router.delete('/:id', adminAuth, async (req, res) => {
try {
const { id } = req.params;
await db('events').where('id', id).update({ is_active: false });
await db('events').where('id', id).update({ is_active: formatBoolean(false) });
res.json({ success: true });
} catch (error) {
@@ -185,7 +189,7 @@ router.post('/:id/extend', adminAuth, [
await db('events').where('id', id).update({
expires_at: newExpiration,
is_active: true // Reactivate if expired
is_active: formatBoolean(true) // Reactivate if expired
});
res.json({ expires_at: newExpiration });
+47 -30
View File
@@ -1,46 +1,23 @@
const express = require('express');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const archiver = require('archiver');
const path = require('path');
const router = express.Router();
const watermarkService = require('../services/watermarkService');
const { verifyGalleryAccess } = require('../middleware/gallery');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Middleware to verify gallery access
async function verifyGalleryAccess(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const event = await db('events')
.where({ id: decoded.eventId, is_active: true, is_archived: false })
.first();
if (!event) {
return res.status(404).json({ error: 'Gallery not found or expired' });
}
req.event = event;
next();
} catch (error) {
console.error('Error verifying gallery access:', error);
res.status(401).json({ error: 'Invalid token', details: error.message });
}
}
// Verify share token
router.get('/:slug/verify-token/:token', async (req, res) => {
try {
const { slug, token } = req.params;
const event = await db('events')
.where({ slug, is_active: true, is_archived: false })
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
.select('id', 'share_link')
.first();
@@ -83,7 +60,11 @@ router.get('/:slug/info', async (req, res) => {
// If token provided, verify it matches the share link
if (token) {
const expectedToken = event.share_link.split('/').pop();
let expectedToken = event.share_link;
// Handle both formats: full URL or just token
if (event.share_link && event.share_link.includes('/')) {
expectedToken = event.share_link.split('/').pop();
}
if (token !== expectedToken) {
return res.status(404).json({ error: 'Invalid gallery link' });
}
@@ -121,7 +102,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
// Get all categories for this event
const categories = await db('photo_categories')
.where(function() {
this.where('is_global', true)
this.where('is_global', formatBoolean(true))
.orWhere('event_id', req.event.id);
})
.orderBy('is_global', 'desc')
@@ -155,8 +136,8 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
url: `/photos/${photo.path}`,
thumbnail_url: photo.thumbnail_path ? `/thumbnails/${path.basename(photo.thumbnail_path)}` : null,
url: `/api/gallery/${req.params.slug}/photo/${photo.id}`,
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
type: photo.type,
category_id: photo.category_id,
category_name: photo.category_name,
@@ -339,6 +320,42 @@ router.get('/:slug/photo/:photoId', verifyGalleryAccess, async (req, res) => {
}
});
// Serve thumbnail
router.get('/:slug/thumbnail/:photoId', verifyGalleryAccess, async (req, res) => {
try {
const { photoId } = req.params;
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.first();
if (!photo || !photo.thumbnail_path) {
return res.status(404).json({ error: 'Thumbnail not found' });
}
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
// Check if file exists
const fs = require('fs').promises;
try {
await fs.access(thumbPath);
} catch (error) {
return res.status(404).json({ error: 'Thumbnail file not found' });
}
// Set appropriate headers
res.setHeader('Content-Type', 'image/jpeg');
res.setHeader('Cache-Control', 'private, max-age=3600');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
// Send file
res.sendFile(path.resolve(thumbPath));
} catch (error) {
console.error('Error serving thumbnail:', error);
res.status(500).json({ error: 'Failed to serve thumbnail' });
}
});
// Get photo stats
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
try {
+2 -1
View File
@@ -1,6 +1,7 @@
const express = require('express');
const path = require('path');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { verifyGalleryAccess } = require('../middleware/gallery');
const watermarkService = require('../services/watermarkService');
const { getStoragePath } = require('../config/storage');
@@ -141,7 +142,7 @@ router.get('/:slug/photo/:photoId/signed/:token', async (req, res) => {
// Get event
const event = await db('events')
.where({ slug })
.where('is_active', true)
.where('is_active', formatBoolean(true))
.first();
if (!event) {
+105 -23
View File
@@ -3,9 +3,17 @@ const { db } = require('../database/db');
const logger = require('../utils/logger');
let transporter = null;
let lastConfigHash = null;
// Generate hash from config for change detection
function generateConfigHash(config) {
const crypto = require('crypto');
const configString = `${config.smtp_host}:${config.smtp_port}:${config.smtp_user}:${config.smtp_pass}:${config.smtp_secure}`;
return crypto.createHash('md5').update(configString).digest('hex');
}
// Initialize transporter from database config
async function initializeTransporter() {
async function initializeTransporter(forceReinit = false) {
try {
const config = await db('email_configs').first();
@@ -14,6 +22,16 @@ async function initializeTransporter() {
return null;
}
// Check if configuration has changed
const currentConfigHash = generateConfigHash(config);
if (!forceReinit && transporter && currentConfigHash === lastConfigHash) {
// Configuration hasn't changed, return existing transporter
return transporter;
}
// Configuration has changed or first initialization
logger.info('Initializing email transporter' + (lastConfigHash && currentConfigHash !== lastConfigHash ? ' (configuration changed)' : ''));
transporter = nodemailer.createTransport({
host: config.smtp_host,
port: config.smtp_port,
@@ -28,9 +46,14 @@ async function initializeTransporter() {
await transporter.verify();
logger.info('Email transporter initialized successfully');
// Update the config hash
lastConfigHash = currentConfigHash;
return transporter;
} catch (error) {
logger.error('Failed to initialize email transporter:', error);
transporter = null;
lastConfigHash = null;
return null;
}
}
@@ -256,11 +279,10 @@ async function processTemplate(template, variables, language = 'en') {
// Send email using template
async function sendTemplateEmail(to, templateKey, variables) {
try {
// Always check for configuration changes before sending
transporter = await initializeTransporter();
if (!transporter) {
transporter = await initializeTransporter();
if (!transporter) {
throw new Error('Email service not configured');
}
throw new Error('Email service not configured');
}
// Get email template
@@ -303,14 +325,33 @@ async function sendTemplateEmail(to, templateKey, variables) {
// Process email queue
async function processEmailQueue() {
logger.info('Email queue processor: Checking for pending emails...');
try {
const pendingEmails = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
.orderBy('created_at', 'asc')
.limit(10);
// Try to initialize transporter if it's null (in case it failed at startup)
if (!transporter) {
logger.info('Transporter not initialized, attempting to initialize...');
transporter = await initializeTransporter();
if (!transporter) {
logger.warn('Email transporter could not be initialized, skipping queue processing');
return;
}
}
let pendingEmails = [];
try {
pendingEmails = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
.orderBy('created_at', 'asc')
.limit(10);
} catch (dbError) {
logger.error('Failed to query email queue:', dbError);
return;
}
if (pendingEmails.length === 0) {
logger.info('Email queue processor: No pending emails found');
return;
}
@@ -318,7 +359,9 @@ async function processEmailQueue() {
for (const email of pendingEmails) {
try {
const emailData = JSON.parse(email.email_data || '{}');
const emailData = typeof email.email_data === 'string'
? JSON.parse(email.email_data || '{}')
: email.email_data || {};
await sendTemplateEmail(
email.recipient_email,
@@ -337,13 +380,24 @@ async function processEmailQueue() {
logger.info(`Email ${email.id} sent successfully`);
} catch (error) {
// Increment retry count
await db('email_queue')
.where('id', email.id)
.update({
retry_count: email.retry_count + 1,
error_message: error.message,
updated_at: new Date()
});
try {
await db('email_queue')
.where('id', email.id)
.update({
retry_count: email.retry_count + 1,
error_message: error.message
});
} catch (updateError) {
logger.error(`Failed to update email retry count for ${email.id}:`, updateError);
// If update fails due to column issue, try without any potential auto-added fields
if (updateError.message && updateError.message.includes('updated_at')) {
logger.warn('Detected updated_at column issue, attempting raw query...');
await db.raw(
'UPDATE email_queue SET retry_count = ?, error_message = ? WHERE id = ?',
[email.retry_count + 1, error.message, email.id]
);
}
}
logger.error(`Failed to send email ${email.id}:`, error);
}
@@ -373,17 +427,45 @@ async function queueEmail(eventId, recipientEmail, emailType, emailData) {
}
}
// Test email connection
async function testEmailConnection() {
try {
if (!transporter) {
await initializeTransporter();
}
if (!transporter) {
return false;
}
await transporter.verify();
return true;
} catch (error) {
logger.error('Email connection test failed:', error);
return false;
}
}
// Start email queue processor
let emailQueueInterval = null;
function startEmailQueueProcessor() {
logger.info('Email queue processor: Attempting to start...');
if (!emailQueueInterval) {
// Process immediately on start
processEmailQueue();
processEmailQueue().catch(err => {
logger.error('Email queue processor: Initial processing failed:', err);
});
// Then process every minute
emailQueueInterval = setInterval(processEmailQueue, 60000);
logger.info('Email queue processor started');
emailQueueInterval = setInterval(() => {
processEmailQueue().catch(err => {
logger.error('Email queue processor: Periodic processing failed:', err);
});
}, 60000);
logger.info('Email queue processor started successfully');
} else {
logger.info('Email queue processor: Already running');
}
}
@@ -407,6 +489,6 @@ module.exports = {
sendTemplateEmail,
processEmailQueue,
queueEmail,
startEmailQueueProcessor,
stopEmailQueueProcessor
stopEmailQueueProcessor,
testEmailConnection
};
+6 -5
View File
@@ -4,6 +4,7 @@ const { archiveEvent } = require('./archiveService');
const { queueEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
const { formatDate } = require('../utils/dateFormatter');
const { formatBoolean } = require('../utils/dbCompat');
function startExpirationChecker() {
// Check every hour for expired events and warnings
@@ -21,8 +22,8 @@ async function checkExpirations() {
// Check for events needing warning emails
const eventsNeedingWarning = await db('events')
.where('is_active', true)
.where('is_archived', false)
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.where('expires_at', '<=', warningDate)
.where('expires_at', '>', now);
@@ -40,8 +41,8 @@ async function checkExpirations() {
// Check for expired events
const expiredEvents = await db('events')
.where('is_active', true)
.where('is_archived', false)
.where('is_active', formatBoolean(true))
.where('is_archived', formatBoolean(false))
.where('expires_at', '<=', now);
for (const event of expiredEvents) {
@@ -74,7 +75,7 @@ async function queueExpirationWarning(event) {
async function handleExpiredEvent(event) {
try {
// Mark as inactive
await db('events').where('id', event.id).update({ is_active: false });
await db('events').where('id', event.id).update({ is_active: formatBoolean(false) });
// Queue expiration emails
await queueEmail(event.id, event.host_email, 'gallery_expired', {
+2 -1
View File
@@ -2,6 +2,7 @@ const chokidar = require('chokidar');
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail } = require('./imageProcessor');
const logger = require('../utils/logger');
@@ -51,7 +52,7 @@ async function processNewPhoto(filePath) {
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
// Find the event
const event = await db('events').where({ slug: eventSlug, is_active: true }).first();
const event = await db('events').where({ slug: eventSlug, is_active: formatBoolean(true) }).first();
if (!event) return;
// Get file stats
+26 -9
View File
@@ -2,6 +2,10 @@ const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
// Configure sharp for better memory management with large batches
sharp.cache(false); // Disable cache to prevent memory buildup
sharp.concurrency(2); // Limit concurrent operations
const THUMBNAIL_WIDTH = 300;
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
@@ -15,16 +19,29 @@ async function generateThumbnail(imagePath) {
// Ensure thumbnail directory exists
await fs.mkdir(thumbnailDir, { recursive: true });
// Generate thumbnail
await sharp(imagePath)
.resize(THUMBNAIL_WIDTH, null, {
withoutEnlargement: true,
fit: 'inside'
try {
// Generate thumbnail with memory-efficient settings
await sharp(imagePath, {
limitInputPixels: 268402689, // ~16k x 16k max
sequentialRead: true // More memory efficient for large images
})
.jpeg({ quality: 80 })
.toFile(thumbnailPath);
return path.relative(getStoragePath(), thumbnailPath);
.resize(THUMBNAIL_WIDTH, null, {
withoutEnlargement: true,
fit: 'inside'
})
.jpeg({
quality: 80,
progressive: true, // Progressive JPEG for better loading
mozjpeg: true // Better compression
})
.toFile(thumbnailPath);
return path.relative(getStoragePath(), thumbnailPath);
} catch (error) {
console.error(`Failed to generate thumbnail for ${filename}:`, error);
// Return null if thumbnail generation fails, don't fail the whole upload
return null;
}
}
module.exports = { generateThumbnail };
+3 -2
View File
@@ -4,6 +4,7 @@
*/
const { db } = require('../database/db');
const { formatBoolean } = require('./dbCompat');
const logger = require('./logger');
// Configuration constants
@@ -59,7 +60,7 @@ async function trackSuccessfulLogin(identifier, ipAddress, userAgent) {
const cutoffTime = new Date(Date.now() - ATTEMPT_WINDOW);
await db('login_attempts')
.where('identifier', identifier)
.where('success', false)
.where('success', formatBoolean(false))
.where('attempt_time', '<', cutoffTime.toISOString())
.delete();
} catch (error) {
@@ -79,7 +80,7 @@ async function checkAccountLockout(identifier) {
// Get recent failed attempts
const failedAttempts = await db('login_attempts')
.where('identifier', identifier)
.where('success', false)
.where('success', formatBoolean(false))
.where('attempt_time', '>=', recentWindow.toISOString())
.orderBy('attempt_time', 'desc')
.limit(MAX_LOGIN_ATTEMPTS);
+15 -1
View File
@@ -11,7 +11,21 @@ async function formatDate(date, language = 'en') {
try {
// Get date format setting from database
const setting = await db('app_settings').where('setting_key', 'general_date_format').first();
const dateConfig = setting ? JSON.parse(setting.setting_value) : DEFAULT_FORMAT;
let dateConfig = DEFAULT_FORMAT;
if (setting && setting.setting_value) {
// Handle both string and object values
if (typeof setting.setting_value === 'string') {
try {
dateConfig = JSON.parse(setting.setting_value);
} catch (e) {
console.warn('Failed to parse date format setting:', e.message);
dateConfig = DEFAULT_FORMAT;
}
} else {
dateConfig = setting.setting_value;
}
}
const dateObj = date instanceof Date ? date : new Date(date);
+133
View File
@@ -0,0 +1,133 @@
/**
* Database Compatibility Utilities
* Handles differences between PostgreSQL and SQLite
*/
// Note: Requiring db here creates circular dependency
// db should be passed as parameter or required where needed
/**
* Get database client type
* @returns {string} 'pg' or 'sqlite3'
*/
function getDbClient() {
return process.env.DATABASE_CLIENT || 'sqlite3';
}
/**
* Check if using PostgreSQL
* @returns {boolean}
*/
function isPostgreSQL() {
return getDbClient() === 'pg';
}
/**
* Handle insert operations that return IDs
* Works with both PostgreSQL and SQLite
* @param {object} query - Knex query builder
* @returns {Promise<number>} The inserted ID
*/
async function insertAndGetId(query) {
const result = await query.returning('id');
// PostgreSQL returns array of objects [{id: 1}]
// SQLite returns array of IDs [1]
return result[0]?.id || result[0];
}
/**
* Format date for database compatibility
* @param {Date} date - JavaScript Date object
* @returns {string} ISO string format that works on both databases
*/
function formatDateForDB(date) {
return date.toISOString();
}
/**
* Add days to a date (database agnostic)
* @param {Date} date - Starting date
* @param {number} days - Number of days to add
* @returns {Date} New date
*/
function addDays(date, days) {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
}
/**
* Get date extraction SQL that works on both databases
* @param {object} db - Knex database instance
* @param {string} column - Column name
* @returns {object} Knex raw query
*/
function dateExtractSQL(db, column) {
if (isPostgreSQL()) {
return db.raw(`DATE(${column})`);
} else {
// SQLite uses date() function
return db.raw(`date(${column})`);
}
}
/**
* Get database size query
* @param {object} db - Knex database instance
* @param {string} dbName - Database name
* @returns {Promise<number>} Size in bytes
*/
async function getDatabaseSize(db, dbName) {
if (isPostgreSQL()) {
const result = await db.raw('SELECT pg_database_size(?) as size', [dbName]);
return result.rows[0]?.size || 0;
} else {
// For SQLite, check file size
const fs = require('fs').promises;
const path = require('path');
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, '../../data/photo_sharing.db');
try {
const stats = await fs.stat(dbPath);
return stats.size;
} catch (error) {
console.error('Error getting SQLite database size:', error);
return 0;
}
}
}
/**
* Handle boolean values for database compatibility
* @param {boolean} value - Boolean value
* @returns {any} Database-appropriate boolean representation
*/
function formatBoolean(value) {
if (isPostgreSQL()) {
return value;
} else {
// SQLite stores booleans as 0/1
return value ? 1 : 0;
}
}
/**
* Parse boolean from database
* @param {any} value - Database boolean value
* @returns {boolean} JavaScript boolean
*/
function parseBoolean(value) {
return Boolean(value);
}
module.exports = {
getDbClient,
isPostgreSQL,
insertAndGetId,
formatDateForDB,
addDays,
dateExtractSQL,
getDatabaseSize,
formatBoolean,
parseBoolean
};
+56 -11
View File
@@ -78,6 +78,16 @@ function validatePassword(password, options = {}) {
}
}
// Skip zxcvbn check if explicitly disabled (for gallery passwords)
if (options.skipStrengthCheck) {
return {
valid: errors.length === 0,
errors,
score: 2, // Default moderate score for gallery passwords
feedback: {}
};
}
// Use zxcvbn for strength analysis
const strength = zxcvbn(password);
@@ -111,7 +121,52 @@ function validatePassword(password, options = {}) {
* @returns {Object} - Validation result
*/
function validatePasswordInContext(password, context, userData = {}) {
// Base validation
// For gallery context, use more lenient validation
if (context === 'gallery') {
// Gallery-specific validation options
const galleryOptions = {
minLength: 6, // Reduced minimum length
requireUppercase: false, // Don't require uppercase for galleries
requireLowercase: false, // Don't require lowercase for galleries
requireNumbers: false, // Numbers are optional
requireSpecialChars: false, // Special chars are optional
preventCommonPasswords: true, // Still prevent common passwords
minStrengthScore: 0, // Accept any score for galleries
skipStrengthCheck: true // Skip zxcvbn strength analysis for galleries
};
// Base validation with gallery-specific options
const result = validatePassword(password, galleryOptions);
// Override validation for common date formats
// Allow passwords like "04.07.2025", "04/07/2025", "04-07-2025"
const datePattern = /^\d{1,2}[.\/-]\d{1,2}[.\/-]\d{4}$/;
if (datePattern.test(password)) {
// Date format is valid for gallery passwords
return {
valid: true,
errors: [],
score: 2,
feedback: {}
};
}
// Additional gallery-specific checks
if (password.length < 6) {
result.valid = false;
result.errors = ['Password must be at least 6 characters long'];
}
// Check if it's too simple (e.g., just "123456")
if (/^\d{1,6}$/.test(password)) {
result.valid = false;
result.errors.push('Password cannot be just numbers. Consider using a date format like "04.07.2025"');
}
return result;
}
// Base validation for other contexts
const result = validatePassword(password);
// Context-specific validation
@@ -136,16 +191,6 @@ function validatePasswordInContext(password, context, userData = {}) {
result.errors.push('Password must not contain parts of your email');
}
}
} else if (context === 'gallery') {
// Gallery passwords can be more lenient for user convenience
// Allow passwords with score >= 1 (weak but acceptable)
if (result.score < 1) {
result.valid = false;
result.errors.push('Password is too simple. Please add more complexity');
}
// Don't check for event name in password - allow date-based passwords
// This allows passwords like "Sommer2025!" which users prefer
}
return result;
View File
-862
View File
@@ -1,862 +0,0 @@
#!/bin/bash
# Complete setup script to create ALL remaining files
echo "========================================="
echo "PicPeak Platform Setup"
echo "========================================="
echo ""
# Function to create directory if it doesn't exist
create_dir() {
if [ ! -d "$1" ]; then
mkdir -p "$1"
echo "Created directory: $1"
fi
}
# Create all necessary directories
echo "Creating directory structure..."
create_dir "backend/src/services"
create_dir "backend/src/utils"
create_dir "backend/src/routes"
create_dir "backend/migrations"
create_dir "backend/scripts"
create_dir "backend/__tests__"
create_dir "frontend/public"
create_dir "frontend/src/components"
create_dir "frontend/src/contexts"
create_dir "frontend/src/hooks"
create_dir "frontend/src/pages/admin"
create_dir "frontend/src/services"
create_dir "frontend/src/config"
create_dir "nginx/sites-enabled"
create_dir "scripts"
create_dir "storage/events/active"
create_dir "storage/events/archived"
create_dir "storage/thumbnails"
create_dir "data"
create_dir "logs"
create_dir "certbot/conf"
create_dir "certbot/www"
# Create .gitkeep files to preserve empty directories
touch storage/events/active/.gitkeep
touch storage/events/archived/.gitkeep
touch storage/thumbnails/.gitkeep
touch data/.gitkeep
touch logs/.gitkeep
echo ""
echo "Creating backend utilities..."
# Create helpers utility
cat > backend/src/utils/helpers.js << 'EOF'
const crypto = require('crypto');
const path = require('path');
function generateToken(length = 32) {
return crypto.randomBytes(length).toString('hex');
}
function sanitizeFilename(filename) {
const basename = path.basename(filename);
return basename.replace(/[^a-zA-Z0-9._-]/g, '_');
}
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
function generateSlug(text) {
return text
.toString()
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
function daysBetween(date1, date2) {
const oneDay = 24 * 60 * 60 * 1000;
const firstDate = new Date(date1);
const secondDate = new Date(date2);
const diffDays = Math.round(Math.abs((firstDate - secondDate) / oneDay));
return diffDays;
}
function isValidEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
function paginate(totalItems, currentPage = 1, pageSize = 20) {
const totalPages = Math.ceil(totalItems / pageSize);
const offset = (currentPage - 1) * pageSize;
return {
totalItems,
currentPage,
pageSize,
totalPages,
offset,
hasNext: currentPage < totalPages,
hasPrev: currentPage > 1
};
}
function asyncHandler(fn) {
return (req, res, next) => {
Promise.resolve(fn(req, res, next)).catch(next);
};
}
function getClientIp(req) {
return req.headers['x-forwarded-for']?.split(',')[0] ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress;
}
module.exports = {
generateToken,
sanitizeFilename,
formatBytes,
generateSlug,
daysBetween,
isValidEmail,
paginate,
asyncHandler,
getClientIp
};
EOF
echo "Creating remaining backend routes..."
# Create admin routes
cat > backend/src/routes/admin.js << 'EOF'
const express = require('express');
const bcrypt = require('bcrypt');
const { adminAuth } = require('../middleware/auth');
const { db } = require('../database/db');
const router = express.Router();
// Dashboard stats
router.get('/stats', adminAuth, async (req, res) => {
try {
const totalEvents = await db('events').count('id as count').first();
const activeEvents = await db('events').where('is_active', true).count('id as count').first();
const archivedEvents = await db('events').where('is_archived', true).count('id as count').first();
const totalPhotos = await db('photos').count('id as count').first();
const upcomingExpirations = await db('events')
.where('is_active', true)
.where('expires_at', '<=', new Date(Date.now() + 7 * 24 * 60 * 60 * 1000))
.orderBy('expires_at', 'asc')
.limit(5);
const recentActivity = await db('access_logs')
.join('events', 'access_logs.event_id', 'events.id')
.select('access_logs.*', 'events.event_name')
.orderBy('access_logs.timestamp', 'desc')
.limit(10);
res.json({
total_events: totalEvents.count,
active_events: activeEvents.count,
archived_events: archivedEvents.count,
total_photos: totalPhotos.count,
upcoming_expirations: upcomingExpirations,
recent_activity: recentActivity
});
} catch (error) {
res.status(500).json({ error: 'Failed to fetch stats' });
}
});
// Email queue management
router.get('/emails', adminAuth, async (req, res) => {
try {
const emails = await db('email_queue')
.join('events', 'email_queue.event_id', 'events.id')
.select('email_queue.*', 'events.event_name')
.orderBy('email_queue.scheduled_at', 'desc')
.limit(50);
res.json(emails);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch emails' });
}
});
// Retry failed email
router.post('/emails/:id/retry', adminAuth, async (req, res) => {
try {
const { id } = req.params;
await db('email_queue').where('id', id).update({
status: 'pending',
retry_count: 0,
error_message: null
});
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to retry email' });
}
});
// Archive management
router.get('/archives', adminAuth, async (req, res) => {
try {
const archives = await db('events')
.where('is_archived', true)
.select('id', 'event_name', 'event_date', 'archive_path', 'archived_at')
.orderBy('archived_at', 'desc');
res.json(archives);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch archives' });
}
});
// Create admin user
router.post('/users', adminAuth, async (req, res) => {
try {
const { username, email, password } = req.body;
const existing = await db('admin_users')
.where('username', username)
.orWhere('email', email)
.first();
if (existing) {
return res.status(400).json({ error: 'User already exists' });
}
const password_hash = await bcrypt.hash(password, 10);
const [userId] = await db('admin_users').insert({
username,
email,
password_hash
});
res.json({ id: userId, username, email });
} catch (error) {
res.status(500).json({ error: 'Failed to create user' });
}
});
module.exports = router;
EOF
echo "Creating deployment scripts..."
# Create backup script
cat > scripts/backup.sh << 'EOF'
#!/bin/bash
BACKUP_DIR="/backup/photo-sharing"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_NAME="backup_${TIMESTAMP}"
mkdir -p "${BACKUP_DIR}/${BACKUP_NAME}"
echo "Starting backup..."
if [ -f data/photo_sharing.db ]; then
echo "Backing up SQLite database..."
cp data/photo_sharing.db "${BACKUP_DIR}/${BACKUP_NAME}/"
else
echo "Backing up PostgreSQL database..."
docker-compose -f docker-compose.prod.yml exec -T db pg_dump -U photoapp photo_sharing > "${BACKUP_DIR}/${BACKUP_NAME}/database.sql"
fi
echo "Backing up active events..."
tar -czf "${BACKUP_DIR}/${BACKUP_NAME}/active_events.tar.gz" -C storage/events active/
cp .env "${BACKUP_DIR}/${BACKUP_NAME}/"
cat > "${BACKUP_DIR}/${BACKUP_NAME}/backup_info.txt" << EOFINFO
Backup created: $(date)
Database: $([ -f data/photo_sharing.db ] && echo "photo_sharing.db" || echo "database.sql")
Active events: active_events.tar.gz
Configuration: .env
EOFINFO
cd "${BACKUP_DIR}"
tar -czf "${BACKUP_NAME}.tar.gz" "${BACKUP_NAME}/"
rm -rf "${BACKUP_NAME}/"
find "${BACKUP_DIR}" -name "backup_*.tar.gz" -mtime +30 -delete
echo "Backup completed: ${BACKUP_DIR}/${BACKUP_NAME}.tar.gz"
EOF
chmod +x scripts/backup.sh
# Create monitoring script
cat > scripts/monitoring.sh << 'EOF'
#!/bin/bash
check_service() {
SERVICE=$1
if docker-compose -f docker-compose.prod.yml ps | grep -q "${SERVICE}.*Up"; then
echo "${SERVICE} is running"
return 0
else
echo "${SERVICE} is down!"
return 1
fi
}
echo "Service Health Check"
echo "==================="
SERVICES_OK=true
check_service "backend" || SERVICES_OK=false
check_service "frontend" || SERVICES_OK=false
check_service "nginx" || SERVICES_OK=false
echo ""
echo "Disk Usage:"
df -h | grep -E '^/dev/' | awk '{print $6 ": " $5 " used"}'
FAILED_EMAILS=$(docker-compose -f docker-compose.prod.yml exec -T backend sqlite3 data/photo_sharing.db "SELECT COUNT(*) FROM email_queue WHERE status='failed' AND retry_count >= 3;" 2>/dev/null || echo "0")
if [ "$FAILED_EMAILS" -gt 0 ]; then
echo ""
echo "⚠️ Warning: $FAILED_EMAILS failed emails in queue"
fi
echo ""
echo "Upcoming Expirations:"
docker-compose -f docker-compose.prod.yml exec -T backend sqlite3 data/photo_sharing.db "SELECT event_name, date(expires_at) as expires FROM events WHERE is_active=1 AND expires_at <= datetime('now', '+7 days') ORDER BY expires_at;" 2>/dev/null || echo "No database connection"
if [ "$SERVICES_OK" = false ]; then
echo ""
echo "⚠️ Some services are down! Run 'docker-compose -f docker-compose.prod.yml up -d' to restart."
exit 1
fi
EOF
chmod +x scripts/monitoring.sh
# Create SSL setup script
cat > scripts/setup-ssl.sh << 'EOF'
#!/bin/bash
set -e
echo "SSL Certificate Setup"
echo "===================="
if [ ! -f .env ]; then
echo "Error: .env file not found. Please run install.sh first."
exit 1
fi
source .env
ADMIN_DOMAIN=$(echo $ADMIN_URL | sed 's|https://||')
FRONTEND_DOMAIN=$(echo $FRONTEND_URL | sed 's|https://||')
if [ -z "$ADMIN_DOMAIN" ] || [ -z "$FRONTEND_DOMAIN" ]; then
echo "Error: Please set ADMIN_URL and FRONTEND_URL in .env file"
exit 1
fi
sed -i "s/admin.photos.yourdomain.com/$ADMIN_DOMAIN/g" nginx/sites-enabled/default.conf
sed -i "s/photos.yourdomain.com/$FRONTEND_DOMAIN/g" nginx/sites-enabled/default.conf
read -p "Enter email for Let's Encrypt notifications: " EMAIL
docker-compose -f docker-compose.prod.yml up -d nginx
sleep 5
echo "Obtaining SSL certificates for $ADMIN_DOMAIN and $FRONTEND_DOMAIN..."
docker-compose -f docker-compose.prod.yml run --rm certbot certonly \
--webroot \
--webroot-path=/var/www/certbot \
--email $EMAIL \
--agree-tos \
--no-eff-email \
-d $ADMIN_DOMAIN \
-d $FRONTEND_DOMAIN
echo "SSL certificates obtained successfully!"
EOF
chmod +x scripts/setup-ssl.sh
# Create update script
cat > scripts/update.sh << 'EOF'
#!/bin/bash
echo "Photo Sharing Platform - Update"
echo "=============================="
echo "Creating backup before update..."
./scripts/backup.sh
echo "Pulling latest changes..."
git pull origin main
echo "Rebuilding services..."
docker-compose -f docker-compose.prod.yml build
echo "Restarting services..."
docker-compose -f docker-compose.prod.yml down
docker-compose -f docker-compose.prod.yml up -d
echo "Running database migrations..."
docker-compose -f docker-compose.prod.yml exec backend npm run migrate
echo "Update completed successfully!"
EOF
chmod +x scripts/update.sh
echo ""
echo "Creating frontend files..."
# Create frontend Dockerfile
cat > frontend/Dockerfile << 'EOF'
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/build /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
EOF
# Create frontend nginx.conf
cat > frontend/nginx.conf << 'EOF'
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
location /photos {
proxy_pass http://backend:3000;
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;
}
}
EOF
# Create minimal frontend files to get started
cat > frontend/public/index.html << 'EOF'
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="Share your event photos securely" />
<title>Photo Gallery</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>
EOF
# Create basic frontend files
cat > frontend/src/index.js << 'EOF'
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
EOF
cat > frontend/src/App.js << 'EOF'
import React from 'react';
function App() {
return (
<div className="App">
<h1>Photo Sharing Platform</h1>
<p>Setup in progress. Please complete the frontend implementation.</p>
</div>
);
}
export default App;
EOF
cat > frontend/src/index.css << 'EOF'
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
EOF
# Create Tailwind config
cat > frontend/tailwind.config.js << 'EOF'
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
colors: {
wedding: {
primary: '#d4a574',
secondary: '#f3e5d0',
accent: '#8b7355'
}
}
},
},
plugins: [],
}
EOF
# Create postcss config
cat > frontend/postcss.config.js << 'EOF'
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
EOF
# Create nginx site config
cat > nginx/sites-enabled/default.conf << 'EOF'
# Redirect HTTP to HTTPS
server {
listen 80;
server_name admin.photos.yourdomain.com photos.yourdomain.com;
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$server_name$request_uri;
}
}
# Admin backend
server {
listen 443 ssl http2;
server_name admin.photos.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/admin.photos.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/admin.photos.yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers off;
client_max_body_size 100M;
location / {
limit_req zone=general burst=20 nodelay;
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
location /api/auth {
limit_req zone=auth burst=5 nodelay;
proxy_pass http://backend:3000;
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;
}
}
# Public frontend
server {
listen 443 ssl http2;
server_name photos.yourdomain.com;
ssl_certificate /etc/letsencrypt/live/photos.yourdomain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/photos.yourdomain.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_prefer_server_ciphers off;
location / {
limit_req zone=general burst=20 nodelay;
proxy_pass http://frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
}
location /api {
limit_req zone=general burst=20 nodelay;
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
location /photos {
proxy_pass http://backend:3000;
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;
# Cache images
proxy_cache_valid 200 30d;
add_header Cache-Control "public, max-age=2592000";
}
}
EOF
# Create DEPLOYMENT.md
cat > DEPLOYMENT.md << 'EOF'
# Production Deployment Guide
## System Requirements
- Ubuntu 20.04+ or similar Linux distribution
- 2GB RAM minimum (4GB recommended)
- 20GB storage minimum
- Docker and Docker Compose
- Valid domain names with DNS configured
## Step-by-Step Deployment
### 1. Server Preparation
```bash
# Update system
sudo apt update && sudo apt upgrade -y
# Install required packages
sudo apt install -y git curl ufw
# Configure firewall
sudo ufw allow 22/tcp
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw enable
```
### 2. Clone and Install
```bash
# Clone repository
cd /opt
sudo git clone https://github.com/yourusername/photo-sharing-platform.git
cd photo-sharing-platform
# Run installation script
sudo ./scripts/install.sh
```
### 3. Configuration
Edit `.env` file:
```bash
sudo nano .env
```
Required settings:
```env
# URLs (use your actual domains)
ADMIN_URL=https://admin.photos.yourdomain.com
FRONTEND_URL=https://photos.yourdomain.com
# Email Configuration
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
EMAIL_FROM=noreply@yourdomain.com
```
### 4. SSL Certificate Setup
```bash
# Configure SSL
sudo ./scripts/setup-ssl.sh
```
### 5. Start Services
```bash
# Build and start all services
docker-compose -f docker-compose.prod.yml build
docker-compose -f docker-compose.prod.yml up -d
# Initialize database
docker-compose -f docker-compose.prod.yml exec backend npm run migrate
```
### 6. Verify Deployment
1. Check service status:
```bash
docker-compose -f docker-compose.prod.yml ps
```
2. View logs:
```bash
docker-compose -f docker-compose.prod.yml logs -f
```
3. Access sites:
- Admin panel: https://admin.photos.yourdomain.com
- Public gallery: https://photos.yourdomain.com
## Post-Deployment
### Configure Automatic Backups
```bash
# Add to crontab
sudo crontab -e
# Add this line for daily backups at 2 AM
0 2 * * * /opt/photo-sharing-platform/scripts/backup.sh
```
### Set Up Monitoring
```bash
# Add health check to crontab
*/5 * * * * /opt/photo-sharing-platform/scripts/monitoring.sh
```
## Security Recommendations
1. Change default admin password immediately
2. Configure firewall rules
3. Enable automatic security updates
4. Monitor access logs regularly
## Troubleshooting
### Services won't start
```bash
# Check logs
docker-compose -f docker-compose.prod.yml logs backend
docker-compose -f docker-compose.prod.yml logs frontend
# Restart services
docker-compose -f docker-compose.prod.yml restart
```
### Email not sending
1. Check SMTP settings in `.env`
2. View email queue in admin panel
3. Check logs: `docker-compose logs backend | grep email`
### SSL certificate issues
```bash
# Renew certificates
docker-compose -f docker-compose.prod.yml run --rm certbot renew
```
EOF
# Set all script permissions
chmod +x scripts/*.sh
echo ""
echo "========================================="
echo "✅ Setup Complete!"
echo "========================================="
echo ""
echo "All core files have been created. The platform structure is ready."
echo ""
echo "Next steps:"
echo "1. Install dependencies:"
echo " cd backend && npm install"
echo " cd ../frontend && npm install"
echo ""
echo "2. Create a .env file from .env.example:"
echo " cp .env.example .env"
echo " nano .env # Edit with your settings"
echo ""
echo "3. Start development environment:"
echo " docker-compose up"
echo ""
echo "4. For production deployment:"
echo " Follow the instructions in DEPLOYMENT.md"
echo ""
echo "Note: The frontend is a basic skeleton. You'll need to implement:"
echo "- Authentication context (AuthContext.js)"
echo "- Page components (Login, Gallery, Admin pages)"
echo "- API service layer"
echo "- UI components"
echo ""
echo "All backend functionality is complete and ready to use!"
echo ""
echo "Default admin credentials: admin / admin123 (change immediately!)"
-265
View File
@@ -1,265 +0,0 @@
version: '3.8'
services:
backend:
image: ${REGISTRY_URL}/photo-sharing-backend:${VERSION:-latest}
networks:
- photo-sharing
- traefik-public
environment:
- NODE_ENV=production
- PORT=3000
- JWT_SECRET_FILE=/run/secrets/jwt_secret
- ADMIN_URL=${ADMIN_URL}
- FRONTEND_URL=${FRONTEND_URL}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_SECURE=${SMTP_SECURE}
- SMTP_USER_FILE=/run/secrets/smtp_user
- SMTP_PASS_FILE=/run/secrets/smtp_pass
- EMAIL_FROM=${EMAIL_FROM}
- UMAMI_URL=${UMAMI_URL}
- UMAMI_WEBSITE_ID=${UMAMI_WEBSITE_ID}
- DB_HOST=db
- DB_PORT=5432
- DB_NAME=${DB_NAME:-photo_sharing}
- DB_USER_FILE=/run/secrets/db_user
- DB_PASSWORD_FILE=/run/secrets/db_password
secrets:
- jwt_secret
- smtp_user
- smtp_pass
- db_user
- db_password
volumes:
- photo-storage:/app/storage
- app-data:/app/data
- app-logs:/app/logs
deploy:
replicas: 3
update_config:
parallelism: 1
delay: 10s
failure_action: rollback
max_failure_ratio: 0.3
restart_policy:
condition: on-failure
delay: 5s
max_attempts: 3
resources:
limits:
cpus: '1'
memory: 512M
reservations:
cpus: '0.25'
memory: 128M
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.constraint-label=traefik-public"
- "traefik.http.routers.backend.rule=Host(`${BACKEND_HOST}`) && PathPrefix(`/api`)"
- "traefik.http.routers.backend.entrypoints=https"
- "traefik.http.routers.backend.tls=true"
- "traefik.http.routers.backend.tls.certresolver=letsencrypt"
- "traefik.http.services.backend.loadbalancer.server.port=3000"
- "traefik.http.services.backend.loadbalancer.healthcheck.path=/api/health"
- "traefik.http.services.backend.loadbalancer.healthcheck.interval=10s"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
frontend:
image: ${REGISTRY_URL}/photo-sharing-frontend:${VERSION:-latest}
networks:
- photo-sharing
- traefik-public
deploy:
replicas: 2
update_config:
parallelism: 1
delay: 10s
failure_action: rollback
restart_policy:
condition: on-failure
resources:
limits:
cpus: '0.5'
memory: 256M
reservations:
cpus: '0.1'
memory: 64M
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.constraint-label=traefik-public"
- "traefik.http.routers.frontend.rule=Host(`${FRONTEND_HOST}`)"
- "traefik.http.routers.frontend.entrypoints=https"
- "traefik.http.routers.frontend.tls=true"
- "traefik.http.routers.frontend.tls.certresolver=letsencrypt"
- "traefik.http.services.frontend.loadbalancer.server.port=80"
- "traefik.http.middlewares.frontend-compress.compress=true"
- "traefik.http.routers.frontend.middlewares=frontend-compress"
db:
image: postgres:14-alpine
networks:
- photo-sharing
environment:
- POSTGRES_USER_FILE=/run/secrets/db_user
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
- POSTGRES_DB=${DB_NAME:-photo_sharing}
secrets:
- db_user
- db_password
volumes:
- postgres-data:/var/lib/postgresql/data
deploy:
placement:
constraints:
- node.labels.db == true
restart_policy:
condition: on-failure
resources:
limits:
cpus: '2'
memory: 1G
reservations:
cpus: '0.5'
memory: 256M
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
interval: 10s
timeout: 5s
retries: 5
# Background workers as separate services for better control
email-worker:
image: ${REGISTRY_URL}/photo-sharing-backend:${VERSION:-latest}
command: ["node", "src/services/emailService.js"]
networks:
- photo-sharing
environment:
- NODE_ENV=production
- JWT_SECRET_FILE=/run/secrets/jwt_secret
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_SECURE=${SMTP_SECURE}
- SMTP_USER_FILE=/run/secrets/smtp_user
- SMTP_PASS_FILE=/run/secrets/smtp_pass
- EMAIL_FROM=${EMAIL_FROM}
secrets:
- jwt_secret
- smtp_user
- smtp_pass
volumes:
- app-data:/app/data
- app-logs:/app/logs
deploy:
replicas: 1
restart_policy:
condition: on-failure
delay: 5s
resources:
limits:
cpus: '0.5'
memory: 256M
expiration-checker:
image: ${REGISTRY_URL}/photo-sharing-backend:${VERSION:-latest}
command: ["node", "src/services/expirationChecker.js"]
networks:
- photo-sharing
environment:
- NODE_ENV=production
volumes:
- photo-storage:/app/storage
- app-data:/app/data
- app-logs:/app/logs
deploy:
replicas: 1
restart_policy:
condition: on-failure
delay: 5s
resources:
limits:
cpus: '0.5'
memory: 256M
archive-worker:
image: ${REGISTRY_URL}/photo-sharing-backend:${VERSION:-latest}
command: ["node", "src/services/archiveService.js"]
networks:
- photo-sharing
environment:
- NODE_ENV=production
volumes:
- photo-storage:/app/storage
- app-data:/app/data
- app-logs:/app/logs
deploy:
replicas: 1
restart_policy:
condition: on-failure
delay: 5s
resources:
limits:
cpus: '1'
memory: 512M
# Umami Analytics
umami:
image: ghcr.io/umami-software/umami:postgresql-latest
networks:
- photo-sharing
- traefik-public
environment:
DATABASE_URL: postgresql://umami:${UMAMI_DB_PASSWORD}@db:5432/umami
DATABASE_TYPE: postgresql
HASH_SALT: ${UMAMI_HASH_SALT}
depends_on:
- db
deploy:
replicas: 1
restart_policy:
condition: on-failure
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.constraint-label=traefik-public"
- "traefik.http.routers.umami.rule=Host(`${UMAMI_HOST}`)"
- "traefik.http.routers.umami.entrypoints=https"
- "traefik.http.routers.umami.tls=true"
- "traefik.http.routers.umami.tls.certresolver=letsencrypt"
- "traefik.http.services.umami.loadbalancer.server.port=3000"
networks:
photo-sharing:
driver: overlay
attachable: true
traefik-public:
external: true
volumes:
postgres-data:
driver: local
photo-storage:
driver: local
app-data:
driver: local
app-logs:
driver: local
secrets:
jwt_secret:
external: true
smtp_user:
external: true
smtp_pass:
external: true
db_user:
external: true
db_password:
external: true
@@ -1,189 +0,0 @@
version: '3.8'
services:
prometheus:
image: prom/prometheus:latest
networks:
- monitoring
- traefik-public
volumes:
- prometheus-data:/prometheus
- ./prometheus.yml:/etc/prometheus/prometheus.yml:ro
command:
- '--config.file=/etc/prometheus/prometheus.yml'
- '--storage.tsdb.path=/prometheus'
- '--web.console.libraries=/usr/share/prometheus/console_libraries'
- '--web.console.templates=/usr/share/prometheus/consoles'
- '--web.enable-lifecycle'
- '--storage.tsdb.retention.time=30d'
deploy:
replicas: 1
placement:
constraints:
- node.labels.monitoring == true
resources:
limits:
memory: 1G
cpus: '1'
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.prometheus.rule=Host(`prometheus.${DOMAIN}`)"
- "traefik.http.routers.prometheus.entrypoints=https"
- "traefik.http.routers.prometheus.tls=true"
- "traefik.http.routers.prometheus.tls.certresolver=letsencrypt"
- "traefik.http.routers.prometheus.middlewares=admin-auth"
- "traefik.http.services.prometheus.loadbalancer.server.port=9090"
grafana:
image: grafana/grafana:latest
networks:
- monitoring
- traefik-public
volumes:
- grafana-data:/var/lib/grafana
- ./grafana/provisioning:/etc/grafana/provisioning:ro
environment:
- GF_SECURITY_ADMIN_USER=${GRAFANA_USER:-admin}
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD}
- GF_USERS_ALLOW_SIGN_UP=false
- GF_SERVER_ROOT_URL=https://grafana.${DOMAIN}
- GF_SMTP_ENABLED=true
- GF_SMTP_HOST=${SMTP_HOST}:${SMTP_PORT}
- GF_SMTP_USER=${SMTP_USER}
- GF_SMTP_PASSWORD=${SMTP_PASS}
- GF_SMTP_FROM_ADDRESS=${EMAIL_FROM}
deploy:
replicas: 1
placement:
constraints:
- node.labels.monitoring == true
resources:
limits:
memory: 512M
cpus: '0.5'
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.grafana.rule=Host(`grafana.${DOMAIN}`)"
- "traefik.http.routers.grafana.entrypoints=https"
- "traefik.http.routers.grafana.tls=true"
- "traefik.http.routers.grafana.tls.certresolver=letsencrypt"
- "traefik.http.services.grafana.loadbalancer.server.port=3000"
loki:
image: grafana/loki:latest
networks:
- monitoring
volumes:
- loki-data:/loki
- ./loki-config.yml:/etc/loki/config.yml:ro
command: -config.file=/etc/loki/config.yml
deploy:
replicas: 1
placement:
constraints:
- node.labels.monitoring == true
resources:
limits:
memory: 1G
cpus: '1'
promtail:
image: grafana/promtail:latest
networks:
- monitoring
volumes:
- /var/log:/var/log:ro
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- ./promtail-config.yml:/etc/promtail/config.yml:ro
command: -config.file=/etc/promtail/config.yml
deploy:
mode: global
resources:
limits:
memory: 256M
cpus: '0.25'
node-exporter:
image: prom/node-exporter:latest
networks:
- monitoring
volumes:
- /proc:/host/proc:ro
- /sys:/host/sys:ro
- /:/rootfs:ro
command:
- '--path.procfs=/host/proc'
- '--path.sysfs=/host/sys'
- '--collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc)($$|/)'
deploy:
mode: global
resources:
limits:
memory: 128M
cpus: '0.1'
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
networks:
- monitoring
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
privileged: true
deploy:
mode: global
resources:
limits:
memory: 256M
cpus: '0.25'
alertmanager:
image: prom/alertmanager:latest
networks:
- monitoring
- traefik-public
volumes:
- alertmanager-data:/alertmanager
- ./alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro
command:
- '--config.file=/etc/alertmanager/alertmanager.yml'
- '--storage.path=/alertmanager'
deploy:
replicas: 1
placement:
constraints:
- node.labels.monitoring == true
resources:
limits:
memory: 256M
cpus: '0.25'
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.alertmanager.rule=Host(`alerts.${DOMAIN}`)"
- "traefik.http.routers.alertmanager.entrypoints=https"
- "traefik.http.routers.alertmanager.tls=true"
- "traefik.http.routers.alertmanager.tls.certresolver=letsencrypt"
- "traefik.http.routers.alertmanager.middlewares=admin-auth"
- "traefik.http.services.alertmanager.loadbalancer.server.port=9093"
networks:
monitoring:
external: true
traefik-public:
external: true
volumes:
prometheus-data:
driver: local
grafana-data:
driver: local
loki-data:
driver: local
alertmanager-data:
driver: local
-65
View File
@@ -1,65 +0,0 @@
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
monitor: 'photo-sharing'
environment: 'production'
alerting:
alertmanagers:
- static_configs:
- targets: ['alertmanager:9093']
rule_files:
- '/etc/prometheus/alerts/*.yml'
scrape_configs:
# Prometheus itself
- job_name: 'prometheus'
static_configs:
- targets: ['localhost:9090']
# Node Exporter
- job_name: 'node-exporter'
dns_sd_configs:
- names:
- 'tasks.node-exporter'
type: 'A'
port: 9100
# Docker containers
- job_name: 'cadvisor'
dns_sd_configs:
- names:
- 'tasks.cadvisor'
type: 'A'
port: 8080
# Traefik
- job_name: 'traefik'
static_configs:
- targets: ['traefik:8082']
# Photo Sharing Backend
- job_name: 'photo-sharing-backend'
dns_sd_configs:
- names:
- 'tasks.photo-sharing_backend'
type: 'A'
port: 3000
metrics_path: '/api/metrics'
# PostgreSQL
- job_name: 'postgres'
static_configs:
- targets: ['photo-sharing_db:9187']
# Loki
- job_name: 'loki'
static_configs:
- targets: ['loki:3100']
# Grafana
- job_name: 'grafana'
static_configs:
- targets: ['grafana:3000']
-134
View File
@@ -1,134 +0,0 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}Photo Sharing Platform Backup Script${NC}"
echo "===================================="
# Configuration
BACKUP_DIR="/opt/photo-sharing/backup"
STACK_NAME="photo-sharing"
RETENTION_DAYS=30
TIMESTAMP=$(date +%Y%m%d-%H%M%S)
BACKUP_NAME="backup-${TIMESTAMP}"
# Create backup directory
mkdir -p $BACKUP_DIR/$BACKUP_NAME
# Function to check if service is running
check_service() {
local service=$1
if docker service ps ${STACK_NAME}_${service} --format "{{.CurrentState}}" | grep -q "Running"; then
return 0
else
return 1
fi
}
# Backup database
echo -e "${GREEN}Backing up database...${NC}"
if check_service "db"; then
DB_CONTAINER=$(docker ps -q -f name=${STACK_NAME}_db -f status=running | head -1)
if [ ! -z "$DB_CONTAINER" ]; then
docker exec $DB_CONTAINER pg_dumpall -U postgres > $BACKUP_DIR/$BACKUP_NAME/database.sql
echo -e "${GREEN}Database backup completed${NC}"
else
echo -e "${RED}Database container not found${NC}"
fi
else
echo -e "${YELLOW}Database service not running, skipping...${NC}"
fi
# Backup photos
echo -e "${GREEN}Backing up photos...${NC}"
if [ -d "/opt/photo-sharing/storage" ]; then
tar -czf $BACKUP_DIR/$BACKUP_NAME/photos.tar.gz -C /opt/photo-sharing storage/
echo -e "${GREEN}Photos backup completed${NC}"
else
echo -e "${YELLOW}Photos directory not found, skipping...${NC}"
fi
# Backup application data
echo -e "${GREEN}Backing up application data...${NC}"
if [ -d "/opt/photo-sharing/data" ]; then
tar -czf $BACKUP_DIR/$BACKUP_NAME/app-data.tar.gz -C /opt/photo-sharing data/
echo -e "${GREEN}Application data backup completed${NC}"
else
echo -e "${YELLOW}Application data directory not found, skipping...${NC}"
fi
# Backup Docker volumes
echo -e "${GREEN}Backing up Docker volumes...${NC}"
for volume in $(docker volume ls -q | grep ${STACK_NAME}); do
echo "Backing up volume: $volume"
docker run --rm \
-v $volume:/data \
-v $BACKUP_DIR/$BACKUP_NAME:/backup \
alpine tar -czf /backup/volume-${volume}.tar.gz -C /data .
done
# Backup configurations
echo -e "${GREEN}Backing up configurations...${NC}"
if [ -f "../../.env.production" ]; then
cp ../../.env.production $BACKUP_DIR/$BACKUP_NAME/
fi
# Export Docker secrets (encrypted)
echo -e "${GREEN}Exporting Docker secrets info...${NC}"
docker secret ls --filter "label=com.docker.stack.namespace=$STACK_NAME" > $BACKUP_DIR/$BACKUP_NAME/secrets-list.txt
# Create backup manifest
echo -e "${GREEN}Creating backup manifest...${NC}"
cat > $BACKUP_DIR/$BACKUP_NAME/manifest.json << EOF
{
"timestamp": "$TIMESTAMP",
"stack_name": "$STACK_NAME",
"hostname": "$(hostname)",
"docker_version": "$(docker version --format '{{.Server.Version}}')",
"services": $(docker service ls --filter "label=com.docker.stack.namespace=$STACK_NAME" --format '{{json .}}' | jq -s .),
"backup_contents": [
"database.sql",
"photos.tar.gz",
"app-data.tar.gz",
"volume-*.tar.gz",
".env.production",
"secrets-list.txt"
]
}
EOF
# Compress entire backup
echo -e "${GREEN}Compressing backup...${NC}"
cd $BACKUP_DIR
tar -czf ${BACKUP_NAME}.tar.gz $BACKUP_NAME/
rm -rf $BACKUP_NAME/
# Upload to S3 (optional)
if [ ! -z "$S3_BACKUP_BUCKET" ] && command -v aws &> /dev/null; then
echo -e "${GREEN}Uploading to S3...${NC}"
aws s3 cp ${BACKUP_NAME}.tar.gz s3://${S3_BACKUP_BUCKET}/photo-sharing/
fi
# Clean up old backups
echo -e "${GREEN}Cleaning up old backups...${NC}"
find $BACKUP_DIR -name "backup-*.tar.gz" -mtime +$RETENTION_DAYS -delete
# Show backup summary
BACKUP_SIZE=$(du -h $BACKUP_DIR/${BACKUP_NAME}.tar.gz | cut -f1)
echo ""
echo -e "${GREEN}Backup completed successfully!${NC}"
echo -e "Backup file: $BACKUP_DIR/${BACKUP_NAME}.tar.gz"
echo -e "Backup size: $BACKUP_SIZE"
echo -e "Retention: $RETENTION_DAYS days"
# Verify backup
echo ""
echo -e "${GREEN}Verifying backup...${NC}"
tar -tzf $BACKUP_DIR/${BACKUP_NAME}.tar.gz | head -10
echo "..."
echo -e "${GREEN}Backup verification complete${NC}"
-111
View File
@@ -1,111 +0,0 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}Docker Secrets Creation Script${NC}"
echo "==============================="
# Function to create or update a secret
create_secret() {
local secret_name=$1
local secret_value=$2
# Check if secret exists
if docker secret ls | grep -q $secret_name; then
echo -e "${YELLOW}Secret '$secret_name' already exists. Skipping...${NC}"
else
echo "$secret_value" | docker secret create $secret_name -
echo -e "${GREEN}Created secret: $secret_name${NC}"
fi
}
# Function to generate random password
generate_password() {
openssl rand -base64 32 | tr -d "=+/" | cut -c1-25
}
# Check if in swarm mode
if ! docker info | grep -q "Swarm: active"; then
echo -e "${RED}Docker is not in swarm mode. Run init-swarm.sh first.${NC}"
exit 1
fi
# Load environment variables if .env.production exists
if [ -f "../../.env.production" ]; then
echo -e "${GREEN}Loading environment variables from .env.production${NC}"
source ../../.env.production
fi
# JWT Secret
if [ -z "$JWT_SECRET" ]; then
JWT_SECRET=$(generate_password)
echo -e "${YELLOW}Generated JWT_SECRET: $JWT_SECRET${NC}"
fi
create_secret "jwt_secret" "$JWT_SECRET"
# Database credentials
if [ -z "$DB_USER" ]; then
DB_USER="photoapp"
fi
if [ -z "$DB_PASSWORD" ]; then
DB_PASSWORD=$(generate_password)
echo -e "${YELLOW}Generated DB_PASSWORD: $DB_PASSWORD${NC}"
fi
create_secret "db_user" "$DB_USER"
create_secret "db_password" "$DB_PASSWORD"
# SMTP credentials
if [ -z "$SMTP_USER" ]; then
read -p "Enter SMTP username: " SMTP_USER
fi
if [ -z "$SMTP_PASS" ]; then
read -sp "Enter SMTP password: " SMTP_PASS
echo
fi
create_secret "smtp_user" "$SMTP_USER"
create_secret "smtp_pass" "$SMTP_PASS"
# Traefik dashboard auth (username:password)
if [ -z "$TRAEFIK_USER" ]; then
TRAEFIK_USER="admin"
fi
if [ -z "$TRAEFIK_PASSWORD" ]; then
TRAEFIK_PASSWORD=$(generate_password)
echo -e "${YELLOW}Generated TRAEFIK_PASSWORD: $TRAEFIK_PASSWORD${NC}"
fi
# Generate htpasswd format
TRAEFIK_AUTH=$(docker run --rm httpd:alpine htpasswd -nb $TRAEFIK_USER $TRAEFIK_PASSWORD)
create_secret "traefik_dashboard_auth" "$TRAEFIK_AUTH"
# OAuth secrets (optional)
if [ ! -z "$OAUTH_CLIENT_SECRET" ]; then
create_secret "oauth_client_secret" "$OAUTH_CLIENT_SECRET"
fi
if [ ! -z "$OAUTH_SECRET" ]; then
create_secret "oauth_secret" "$OAUTH_SECRET"
fi
# Drone CI secrets
if [ ! -z "$DRONE_RPC_SECRET" ]; then
create_secret "drone_rpc_secret" "$DRONE_RPC_SECRET"
fi
echo ""
echo -e "${GREEN}Secrets creation complete!${NC}"
echo ""
echo -e "${YELLOW}Important: Save these generated values in a secure location:${NC}"
echo "JWT_SECRET=$JWT_SECRET"
echo "DB_PASSWORD=$DB_PASSWORD"
echo "TRAEFIK_USER=$TRAEFIK_USER"
echo "TRAEFIK_PASSWORD=$TRAEFIK_PASSWORD"
echo ""
echo -e "${GREEN}Next steps:${NC}"
echo "1. Update .env.production with the generated values"
echo "2. Deploy Traefik: ./deploy-traefik.sh"
echo "3. Deploy the application: ./deploy.sh"
-196
View File
@@ -1,196 +0,0 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
echo -e "${GREEN}PicPeak Deployment Script${NC}"
echo "========================================"
# Default values
STACK_NAME="picpeak"
ENV_FILE="../../.env.production"
REGISTRY_URL="${REGISTRY_URL:-}"
VERSION="${VERSION:-latest}"
# Parse command line arguments
while [[ $# -gt 0 ]]; do
case $1 in
--env)
ENV_FILE="$2"
shift 2
;;
--registry)
REGISTRY_URL="$2"
shift 2
;;
--version)
VERSION="$2"
shift 2
;;
--stack-name)
STACK_NAME="$2"
shift 2
;;
--help)
echo "Usage: $0 [options]"
echo "Options:"
echo " --env FILE Path to environment file (default: ../../.env.production)"
echo " --registry URL Docker registry URL"
echo " --version VERSION Image version to deploy (default: latest)"
echo " --stack-name NAME Stack name (default: picpeak)"
exit 0
;;
*)
echo -e "${RED}Unknown option: $1${NC}"
exit 1
;;
esac
done
# Check if Docker is in swarm mode
if ! docker info | grep -q "Swarm: active"; then
echo -e "${RED}Docker is not in swarm mode. Run init-swarm.sh first.${NC}"
exit 1
fi
# Check if environment file exists
if [ ! -f "$ENV_FILE" ]; then
echo -e "${RED}Environment file not found: $ENV_FILE${NC}"
echo "Please create it from .env.production.example"
exit 1
fi
# Load environment variables
echo -e "${GREEN}Loading environment variables...${NC}"
set -a
source "$ENV_FILE"
set +a
# Export deployment variables
export REGISTRY_URL
export VERSION
# Validate required environment variables
required_vars=(
"FRONTEND_HOST"
"BACKEND_HOST"
"ADMIN_URL"
"FRONTEND_URL"
"ACME_EMAIL"
"DB_NAME"
"EMAIL_FROM"
)
echo -e "${GREEN}Validating configuration...${NC}"
for var in "${required_vars[@]}"; do
if [ -z "${!var}" ]; then
echo -e "${RED}Missing required environment variable: $var${NC}"
exit 1
fi
done
# Check if Traefik is running
if ! docker service ls | grep -q "traefik_traefik"; then
echo -e "${YELLOW}Traefik is not running. Deploy it first with:${NC}"
echo "cd ../traefik && docker stack deploy -c docker-compose.traefik.yml traefik"
exit 1
fi
# Check if secrets exist
echo -e "${GREEN}Checking Docker secrets...${NC}"
required_secrets=(
"jwt_secret"
"db_user"
"db_password"
"smtp_user"
"smtp_pass"
)
for secret in "${required_secrets[@]}"; do
if ! docker secret ls | grep -q $secret; then
echo -e "${RED}Missing required secret: $secret${NC}"
echo "Run create-secrets.sh first"
exit 1
fi
done
# Pull latest images if registry is specified
if [ ! -z "$REGISTRY_URL" ]; then
echo -e "${GREEN}Pulling latest images...${NC}"
docker pull ${REGISTRY_URL}/photo-sharing-backend:${VERSION} || true
docker pull ${REGISTRY_URL}/photo-sharing-frontend:${VERSION} || true
fi
# Deploy the stack
echo -e "${GREEN}Deploying stack: $STACK_NAME${NC}"
echo -e "${BLUE}Version: $VERSION${NC}"
echo -e "${BLUE}Registry: ${REGISTRY_URL:-local}${NC}"
cd ..
docker stack deploy \
-c docker-stack.yml \
--with-registry-auth \
$STACK_NAME
# Wait for services to start
echo -e "${GREEN}Waiting for services to start...${NC}"
sleep 10
# Check service status
echo -e "${GREEN}Service status:${NC}"
docker service ls --filter "label=com.docker.stack.namespace=$STACK_NAME"
# Wait for database to be ready
echo -e "${GREEN}Waiting for database to be ready...${NC}"
max_attempts=30
attempt=1
while [ $attempt -le $max_attempts ]; do
if docker exec $(docker ps -q -f name=${STACK_NAME}_db) pg_isready -U postgres > /dev/null 2>&1; then
echo -e "${GREEN}Database is ready!${NC}"
break
fi
echo -n "."
sleep 2
attempt=$((attempt + 1))
done
if [ $attempt -gt $max_attempts ]; then
echo -e "${RED}Database failed to start in time${NC}"
exit 1
fi
# Run database migrations
echo -e "${GREEN}Running database migrations...${NC}"
sleep 5
docker exec $(docker ps -q -f name=${STACK_NAME}_backend -f status=running | head -1) npm run migrate || {
echo -e "${YELLOW}Migration failed. This might be normal if migrations already ran.${NC}"
}
# Show deployment information
echo ""
echo -e "${GREEN}Deployment complete!${NC}"
echo ""
echo -e "${BLUE}Access URLs:${NC}"
echo "Frontend: https://${FRONTEND_HOST}"
echo "Backend API: https://${BACKEND_HOST}/api"
if [ ! -z "$UMAMI_HOST" ]; then
echo "Analytics: https://${UMAMI_HOST}"
fi
if [ ! -z "$TRAEFIK_HOST" ]; then
echo "Traefik Dashboard: https://${TRAEFIK_HOST}/dashboard/"
fi
echo ""
echo -e "${BLUE}Useful commands:${NC}"
echo "View logs: docker service logs ${STACK_NAME}_backend"
echo "Scale service: docker service scale ${STACK_NAME}_backend=5"
echo "Update service: docker service update ${STACK_NAME}_backend"
echo "Remove stack: docker stack rm $STACK_NAME"
echo ""
echo -e "${GREEN}Health check:${NC}"
curl -s -o /dev/null -w "Frontend: %{http_code}\n" https://${FRONTEND_HOST}/health || true
curl -s -o /dev/null -w "Backend: %{http_code}\n" https://${BACKEND_HOST}/api/health || true
-70
View File
@@ -1,70 +0,0 @@
#!/bin/bash
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}Docker Swarm Initialization Script${NC}"
echo "======================================"
# Check if running as root
if [[ $EUID -ne 0 ]]; then
echo -e "${RED}This script must be run as root${NC}"
exit 1
fi
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo -e "${RED}Docker is not installed. Please install Docker first.${NC}"
exit 1
fi
# Check if already in swarm mode
if docker info | grep -q "Swarm: active"; then
echo -e "${YELLOW}This node is already part of a swarm.${NC}"
docker node ls
exit 0
fi
# Initialize swarm
echo -e "${GREEN}Initializing Docker Swarm...${NC}"
ADVERTISE_ADDR=${1:-$(hostname -I | awk '{print $1}')}
docker swarm init --advertise-addr $ADVERTISE_ADDR
# Create overlay networks
echo -e "${GREEN}Creating overlay networks...${NC}"
docker network create --driver overlay --attachable traefik-public || true
docker network create --driver overlay --attachable monitoring || true
# Label the node
echo -e "${GREEN}Labeling manager node...${NC}"
NODE_ID=$(docker info -f '{{.Swarm.NodeID}}')
docker node update --label-add db=true $NODE_ID
docker node update --label-add monitoring=true $NODE_ID
# Create required directories
echo -e "${GREEN}Creating required directories...${NC}"
mkdir -p /opt/photo-sharing/{storage,data,logs,backup}
mkdir -p /opt/traefik/letsencrypt
mkdir -p /opt/monitoring/{prometheus,grafana,loki}
# Set permissions
chown -R 1000:1000 /opt/photo-sharing
chmod -R 755 /opt/photo-sharing
echo -e "${GREEN}Swarm initialization complete!${NC}"
echo ""
echo "Manager join token:"
docker swarm join-token manager
echo ""
echo "Worker join token:"
docker swarm join-token worker
echo ""
echo -e "${GREEN}Next steps:${NC}"
echo "1. Join worker nodes using the token above"
echo "2. Create secrets using create-secrets.sh"
echo "3. Deploy Traefik using deploy-traefik.sh"
echo "4. Deploy the application stack using deploy.sh"
-124
View File
@@ -1,124 +0,0 @@
version: '3.8'
services:
traefik:
image: traefik:v2.10
ports:
- target: 80
published: 80
mode: host
- target: 443
published: 443
mode: host
networks:
- traefik-public
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- traefik-certificates:/letsencrypt
environment:
- TRAEFIK_API=true
- TRAEFIK_API_DASHBOARD=true
- TRAEFIK_API_DEBUG=false
- TRAEFIK_LOG_LEVEL=INFO
- TRAEFIK_PROVIDERS_DOCKER=true
- TRAEFIK_PROVIDERS_DOCKER_SWARMMODE=true
- TRAEFIK_PROVIDERS_DOCKER_EXPOSEDBYDEFAULT=false
- TRAEFIK_PROVIDERS_DOCKER_NETWORK=traefik-public
- TRAEFIK_ENTRYPOINTS_HTTP_ADDRESS=:80
- TRAEFIK_ENTRYPOINTS_HTTPS_ADDRESS=:443
# Redirect HTTP to HTTPS
- TRAEFIK_ENTRYPOINTS_HTTP_HTTP_REDIRECTIONS_ENTRYPOINT_TO=https
- TRAEFIK_ENTRYPOINTS_HTTP_HTTP_REDIRECTIONS_ENTRYPOINT_SCHEME=https
# Let's Encrypt
- TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_EMAIL=${ACME_EMAIL}
- TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_STORAGE=/letsencrypt/acme.json
- TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_HTTPCHALLENGE=true
- TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_HTTPCHALLENGE_ENTRYPOINT=http
# Enable metrics
- TRAEFIK_METRICS_PROMETHEUS=true
- TRAEFIK_METRICS_PROMETHEUS_ENTRYPOINT=metrics
- TRAEFIK_ENTRYPOINTS_METRICS_ADDRESS=:8082
deploy:
mode: global
placement:
constraints:
- node.role == manager
update_config:
parallelism: 1
delay: 10s
restart_policy:
condition: on-failure
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.constraint-label=traefik-public"
# Dashboard
- "traefik.http.routers.traefik-dashboard.rule=Host(`${TRAEFIK_HOST}`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))"
- "traefik.http.routers.traefik-dashboard.entrypoints=https"
- "traefik.http.routers.traefik-dashboard.tls=true"
- "traefik.http.routers.traefik-dashboard.tls.certresolver=letsencrypt"
- "traefik.http.routers.traefik-dashboard.service=api@internal"
- "traefik.http.routers.traefik-dashboard.middlewares=admin-auth"
# Basic auth for dashboard
- "traefik.http.middlewares.admin-auth.basicauth.users=${TRAEFIK_DASHBOARD_AUTH}"
# Global redirect to https
- "traefik.http.middlewares.redirect-to-https.redirectscheme.scheme=https"
# Security headers
- "traefik.http.middlewares.security-headers.headers.frameDeny=true"
- "traefik.http.middlewares.security-headers.headers.contentTypeNosniff=true"
- "traefik.http.middlewares.security-headers.headers.browserXssFilter=true"
- "traefik.http.middlewares.security-headers.headers.stsSeconds=31536000"
- "traefik.http.middlewares.security-headers.headers.stsIncludeSubdomains=true"
- "traefik.http.middlewares.security-headers.headers.stsPreload=true"
# Rate limiting
- "traefik.http.middlewares.rate-limit.ratelimit.average=100"
- "traefik.http.middlewares.rate-limit.ratelimit.burst=50"
# API service
- "traefik.http.services.traefik.loadbalancer.server.port=8080"
healthcheck:
test: ["CMD", "traefik", "healthcheck"]
interval: 30s
timeout: 3s
retries: 3
# Traefik Forward Auth for advanced authentication (optional)
traefik-forward-auth:
image: thomseddon/traefik-forward-auth:latest
networks:
- traefik-public
environment:
- DEFAULT_PROVIDER=generic-oauth
- PROVIDERS_GENERIC_OAUTH_AUTH_URL=${OAUTH_AUTH_URL}
- PROVIDERS_GENERIC_OAUTH_TOKEN_URL=${OAUTH_TOKEN_URL}
- PROVIDERS_GENERIC_OAUTH_USER_URL=${OAUTH_USER_URL}
- PROVIDERS_GENERIC_OAUTH_CLIENT_ID=${OAUTH_CLIENT_ID}
- PROVIDERS_GENERIC_OAUTH_CLIENT_SECRET=${OAUTH_CLIENT_SECRET}
- SECRET=${OAUTH_SECRET}
- COOKIE_DOMAIN=${COOKIE_DOMAIN}
- INSECURE_COOKIE=false
- LOG_LEVEL=info
- URL_PATH=/_oauth
- WHITELIST=${OAUTH_WHITELIST}
deploy:
replicas: 2
restart_policy:
condition: on-failure
labels:
- "traefik.enable=true"
- "traefik.docker.network=traefik-public"
- "traefik.http.routers.traefik-forward-auth.rule=Host(`${FRONTEND_HOST}`) && PathPrefix(`/_oauth`)"
- "traefik.http.routers.traefik-forward-auth.entrypoints=https"
- "traefik.http.routers.traefik-forward-auth.tls=true"
- "traefik.http.routers.traefik-forward-auth.tls.certresolver=letsencrypt"
- "traefik.http.routers.traefik-forward-auth.middlewares=auth-verify"
- "traefik.http.services.traefik-forward-auth.loadbalancer.server.port=4181"
- "traefik.http.middlewares.auth-verify.forwardauth.address=http://traefik-forward-auth:4181"
- "traefik.http.middlewares.auth-verify.forwardauth.authResponseHeaders=X-Forwarded-User"
networks:
traefik-public:
external: true
volumes:
traefik-certificates:
driver: local
-93
View File
@@ -1,93 +0,0 @@
# Static configuration
global:
checkNewVersion: true
sendAnonymousUsage: false
api:
dashboard: true
debug: false
# Entry Points
entryPoints:
http:
address: ":80"
http:
redirections:
entryPoint:
to: https
scheme: https
priority: 1000
https:
address: ":443"
http:
tls:
certResolver: letsencrypt
domains:
- main: "${FRONTEND_HOST}"
- main: "${BACKEND_HOST}"
- main: "${UMAMI_HOST}"
forwardedHeaders:
trustedIPs:
- "127.0.0.1/32"
- "10.0.0.0/8"
- "172.16.0.0/12"
- "192.168.0.0/16"
metrics:
address: ":8082"
# Providers
providers:
docker:
swarmMode: true
exposedByDefault: false
network: traefik-public
watch: true
file:
directory: /etc/traefik/dynamic
watch: true
# Certificate Resolvers
certificatesResolvers:
letsencrypt:
acme:
email: ${ACME_EMAIL}
storage: /letsencrypt/acme.json
httpChallenge:
entryPoint: http
# Staging server for testing
# caServer: https://acme-staging-v02.api.letsencrypt.org/directory
# Logs
log:
level: INFO
format: json
accessLog:
format: json
filters:
statusCodes:
- "200-299"
- "400-499"
- "500-599"
retryAttempts: true
minDuration: "10ms"
# Metrics
metrics:
prometheus:
entryPoint: metrics
addEntryPointsLabels: true
addServicesLabels: true
buckets:
- 0.1
- 0.3
- 1.2
- 5.0
# Ping
ping:
entryPoint: traefik
# Pilot
pilot:
enabled: false
-61
View File
@@ -1,61 +0,0 @@
# Development Docker Compose Configuration
version: '3.8'
services:
backend:
build:
context: ./backend
dockerfile: Dockerfile
ports:
- "3001:3000"
environment:
- NODE_ENV=development
- PORT=3000
- JWT_SECRET=dev-secret-change-in-production
- ADMIN_URL=http://localhost:3005
- FRONTEND_URL=http://localhost:3005
- DATABASE_CLIENT=sqlite3
- DATABASE_PATH=./data/photo_sharing.db
# Email - uses Mailhog
- SMTP_HOST=mailhog
- SMTP_PORT=1025
- SMTP_SECURE=false
- EMAIL_FROM=noreply@photo-sharing.local
volumes:
- ./backend:/app
- /app/node_modules
- ./storage:/app/storage
- ./data:/app/data
- ./logs:/app/logs
depends_on:
- mailhog
command: sh -c "npm install && npm run dev"
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:3000/api/health"]
interval: 30s
timeout: 10s
retries: 3
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
ports:
- "3005:80"
environment:
- NODE_ENV=development
volumes:
- ./frontend/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- backend
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/health"]
interval: 30s
timeout: 10s
retries: 3
mailhog:
image: mailhog/mailhog:latest
ports:
- "1025:1025" # SMTP
- "8025:8025" # Web UI
+59
View File
@@ -0,0 +1,59 @@
# Nginx Configuration Fix for Photo Authentication
If photos and thumbnails are not loading in gallery view but work in admin, it's likely that the Authorization header is being stripped by nginx or another reverse proxy.
## Common Issue
The `Authorization` header is often not passed through by default in nginx proxy configurations.
## Fix
Add these lines to your nginx configuration for the PicPeak location block:
```nginx
location / {
proxy_pass http://localhost:3001;
# Important: Pass the Authorization header
proxy_pass_header Authorization;
proxy_set_header Authorization $http_authorization;
# Other standard proxy headers
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;
}
```
## Alternative Fix Using Traefik
If using Traefik, ensure headers are passed:
```yaml
services:
picpeak:
labels:
- "traefik.http.middlewares.picpeak-headers.headers.customrequestheaders.Authorization="
```
## Testing
1. Check if Authorization header is reaching the backend:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" https://picpeak.yourdomain.com/thumbnails/test.jpg -v
```
2. Check nginx logs to see if the header is present:
```bash
tail -f /var/log/nginx/access.log
```
## Docker Compose Fix
If using docker-compose with nginx proxy, add:
```yaml
environment:
- NGINX_PROXY_PASS_HEADER=Authorization
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 450 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 390 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 412 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.0.24",
"version": "1.0.50",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.24",
"version": "1.0.50",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-link": "^2.25.0",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.24",
"version": "1.0.50",
"type": "module",
"scripts": {
"dev": "vite",
+85 -26
View File
@@ -18,6 +18,8 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const [isUploading, setIsUploading] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState(0);
const [currentChunk, setCurrentChunk] = useState(0);
const [totalChunks, setTotalChunks] = useState(0);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@@ -32,6 +34,20 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const imageFiles = files.filter(file =>
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
);
// Check total file count with existing files
const totalFiles = selectedFiles.length + imageFiles.length;
if (totalFiles > 500) {
const allowedNewFiles = 500 - selectedFiles.length;
if (allowedNewFiles <= 0) {
toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed');
return;
}
toast.warning(t('upload.someFilesSkipped') || `Only ${allowedNewFiles} more files can be added (500 max)`);
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
return;
}
setSelectedFiles(prev => [...prev, ...imageFiles]);
};
@@ -41,38 +57,64 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const handleUpload = async () => {
if (selectedFiles.length === 0) return;
// Validate file count
if (selectedFiles.length > 500) {
toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once');
return;
}
setIsUploading(true);
setUploadProgress(0);
const formData = new FormData();
selectedFiles.forEach((file, index) => {
console.log(`Adding file ${index}: ${file.name}, size: ${file.size}`);
formData.append('photos', file);
});
// For large uploads, chunk the files to prevent memory issues
const CHUNK_SIZE = 50; // Upload 50 files at a time
const chunks = [];
if (selectedCategoryId) {
formData.append('category_id', selectedCategoryId.toString());
}
// Debug: Log FormData contents
console.log('FormData entries:');
for (let pair of formData.entries()) {
console.log(pair[0], pair[1]);
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
chunks.push(selectedFiles.slice(i, i + CHUNK_SIZE));
}
setTotalChunks(chunks.length);
let totalUploaded = 0;
let failedFiles = [];
try {
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
// Don't set Content-Type header - axios will set it with the boundary
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
const progress = Math.round((progressEvent.loaded * 100) / progressEvent.total);
setUploadProgress(progress);
}
},
});
for (let chunkIndex = 0; chunkIndex < chunks.length; chunkIndex++) {
setCurrentChunk(chunkIndex + 1);
const chunk = chunks[chunkIndex];
const formData = new FormData();
chunk.forEach((file) => {
formData.append('photos', file);
});
if (selectedCategoryId) {
formData.append('category_id', selectedCategoryId.toString());
}
console.log('Upload result:', response.data);
try {
const response = await api.post(`/admin/events/${eventId}/upload`, formData, {
onUploadProgress: (progressEvent) => {
if (progressEvent.total) {
// Calculate overall progress across all chunks
const chunkProgress = progressEvent.loaded / progressEvent.total;
const overallProgress = ((chunkIndex + chunkProgress) / chunks.length) * 100;
setUploadProgress(Math.round(overallProgress));
}
},
});
totalUploaded += chunk.length;
console.log(`Chunk ${chunkIndex + 1}/${chunks.length} uploaded:`, response.data);
} catch (error: any) {
console.error(`Error uploading chunk ${chunkIndex + 1}:`, error);
failedFiles.push(...chunk.map(f => f.name));
// Continue with next chunk even if one fails
continue;
}
}
// Clear selected files
setSelectedFiles([]);
@@ -80,8 +122,15 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
fileInputRef.current.value = '';
}
// Show success message
toast.success(t('toast.uploadSuccess'));
// Show appropriate message
if (failedFiles.length === 0) {
toast.success(t('upload.uploadComplete') || `Successfully uploaded ${totalUploaded} files`);
} else {
toast.warning(
t('upload.someFilesFailed') ||
`Uploaded ${totalUploaded} files. ${failedFiles.length} files failed.`
);
}
// Call callback
if (onUploadComplete) {
@@ -93,6 +142,8 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
} finally {
setIsUploading(false);
setUploadProgress(0);
setCurrentChunk(0);
setTotalChunks(0);
}
};
@@ -203,7 +254,10 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{isUploading && (
<div className="mt-4">
<div className="flex justify-between text-sm text-neutral-600 mb-1">
<span>{t('upload.uploading')}</span>
<span>
{t('upload.uploading')}
{totalChunks > 1 && ` (${t('common.chunk') || 'Chunk'} ${currentChunk}/${totalChunks})`}
</span>
<span>{uploadProgress}%</span>
</div>
<div className="w-full bg-neutral-200 rounded-full h-2">
@@ -212,6 +266,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
style={{ width: `${uploadProgress}%` }}
/>
</div>
{totalChunks > 1 && (
<p className="text-xs text-neutral-500 mt-1">
{t('upload.uploadingChunks') || `Uploading ${selectedFiles.length} files in ${totalChunks} batches...`}
</p>
)}
</div>
)}
</div>
@@ -3,9 +3,10 @@ import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { Info } from 'lucide-react';
import { api } from '../../config/api';
import packageJson from '../../../package.json';
// Frontend version from package.json
const FRONTEND_VERSION = '1.0.0';
const FRONTEND_VERSION = packageJson.version;
interface SystemVersion {
backend: string;
@@ -62,9 +62,14 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
let imageUrl = src;
// Build full URL for the image
const fullImageUrl = imageUrl.startsWith('/') ? buildResourceUrl(imageUrl) : imageUrl;
// For API paths that start with /admin, we need to prepend /api
const fullImageUrl = imageUrl.startsWith('/admin')
? buildResourceUrl(`/api${imageUrl}`)
: imageUrl.startsWith('/')
? buildResourceUrl(imageUrl)
: imageUrl;
console.log('Fetching authenticated image:', fullImageUrl);
// console.log('Fetching authenticated image:', fullImageUrl);
const response = await fetch(fullImageUrl, {
headers: {
'Authorization': `Bearer ${token}`
+1 -1
View File
@@ -58,7 +58,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
{...props}
/>
{rightIcon && (
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
<span className="text-neutral-500">{rightIcon}</span>
</div>
)}
@@ -52,7 +52,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { watermarkEnabled } = useWatermarkSettings();
// Fetch photos
const { data, isLoading, error } = useGalleryPhotos(slug);
const { data, isLoading, error, refetch } = useGalleryPhotos(slug);
// Debug logging
useEffect(() => {
@@ -294,11 +294,20 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
if (error || !data) {
// Check if it's an authentication error (401)
const is401Error = (error as any)?.response?.status === 401;
if (is401Error) {
// Authentication failed - logout and let the parent component handle re-authentication
logout();
return null;
}
return (
<div className="min-h-screen bg-neutral-50 flex items-center justify-center">
<div className="text-center">
<p className="text-lg text-neutral-600">{t('gallery.failedToLoad')}</p>
<Button onClick={() => window.location.reload()} className="mt-4">
<Button onClick={() => refetch()} className="mt-4">
{t('gallery.tryAgain')}
</Button>
</div>
@@ -162,7 +162,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-10"
className="absolute top-4 right-4 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
aria-label="Close"
>
<X className="w-6 h-6 text-white" />
@@ -171,7 +171,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{/* Navigation buttons */}
<button
onClick={goToPrevious}
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-10"
className="absolute left-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
aria-label="Previous photo"
>
<ChevronLeft className="w-6 h-6 text-white" />
@@ -179,20 +179,19 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
<button
onClick={goToNext}
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-10"
className="absolute right-4 top-1/2 -translate-y-1/2 p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors z-20"
aria-label="Next photo"
>
<ChevronRight className="w-6 h-6 text-white" />
</button>
{/* Bottom toolbar */}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 z-20">
<div className="max-w-4xl mx-auto flex items-center justify-between">
<div className="text-white">
<p className="text-sm opacity-75">
{currentIndex + 1} / {photos.length}
</p>
<p className="font-medium">{currentPhoto.filename}</p>
</div>
<div className="flex items-center gap-2">
@@ -231,7 +230,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{/* Image container */}
<div
className="absolute inset-0 flex items-center justify-center"
className="absolute inset-0 flex items-center justify-center z-0"
onClick={handleImageClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
@@ -257,7 +256,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
</div>
{/* Touch/swipe indicators for mobile */}
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden">
<div className="absolute bottom-20 left-1/2 -translate-x-1/2 text-white text-sm opacity-50 pointer-events-none md:hidden z-20">
Swipe to navigate
</div>
</div>
+37 -14
View File
@@ -32,14 +32,24 @@ api.interceptors.request.use(
config.headers.Authorization = `Bearer ${token}`;
}
} else {
// For gallery routes, get the slug from the URL path
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
// For gallery routes, try to extract slug from the request URL first
const galleryMatch = config.url?.match(/\/gallery\/([^\/]+)/);
if (galleryMatch && galleryMatch[1]) {
const gallerySlug = galleryMatch[1];
const token = localStorage.getItem(`gallery_token_${gallerySlug}`);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
} else {
// Fallback to getting slug from the current page URL
const pathParts = window.location.pathname.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
const token = localStorage.getItem(`gallery_token_${gallerySlug}`);
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
}
}
}
@@ -73,21 +83,34 @@ api.interceptors.response.use(
}
if (error.response?.status === 401) {
// Redirect to appropriate login
// Check if it's an admin route
const isAdminRoute = error.config?.url?.includes('/admin');
const currentPath = window.location.pathname;
if (isAdminRoute) {
// Clear admin token on unauthorized
Cookies.remove(ADMIN_TOKEN_KEY);
window.location.href = '/admin/login';
// Only redirect if we're not already on the admin login page
if (!currentPath.includes('/admin/login')) {
window.location.href = '/admin/login';
}
} else {
// For gallery routes, clear gallery-specific token and redirect
const currentPath = window.location.pathname;
const pathParts = currentPath.split('/');
if (pathParts[1] === 'gallery' && pathParts[2]) {
const gallerySlug = pathParts[2];
localStorage.removeItem(`gallery_token_${gallerySlug}`);
localStorage.removeItem(`gallery_event_${gallerySlug}`);
window.location.href = `/gallery/${gallerySlug}`;
// For gallery routes, check if the error is from a gallery API call
const galleryMatch = error.config?.url?.match(/\/gallery\/([^\/]+)/);
// Don't redirect if we're on any gallery page (to avoid redirect loops during login)
if (currentPath.startsWith('/gallery/')) {
// If we have a gallery match from the API URL, clear that specific gallery's token
if (galleryMatch && galleryMatch[1]) {
const gallerySlug = galleryMatch[1];
localStorage.removeItem(`gallery_token_${gallerySlug}`);
localStorage.removeItem(`gallery_event_${gallerySlug}`);
}
// Don't redirect - let the component handle the auth state
} else {
// We're not on a gallery page but got a 401 from a gallery API
// This shouldn't happen in normal flow, but if it does, redirect to homepage
window.location.href = '/';
}
}
}
+2
View File
@@ -18,6 +18,8 @@ export const useGalleryPhotos = (slug: string, enabled: boolean = true) => {
enabled,
retry: 1,
staleTime: 5 * 60 * 1000, // 5 minutes
// Add a small delay to ensure auth token is properly set
retryDelay: 100,
});
};
+6 -4
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { settingsService } from '../services/settings.service';
import { api } from '../config/api';
export function useWatermarkSettings() {
const [watermarkEnabled, setWatermarkEnabled] = useState(false);
@@ -8,11 +8,13 @@ export function useWatermarkSettings() {
useEffect(() => {
const fetchSettings = async () => {
try {
const settings = await settingsService.getSettingsByType('branding');
const brandingSettings = settingsService.formatBrandingSettings(settings);
setWatermarkEnabled(brandingSettings.watermark_enabled);
// Use public settings endpoint that doesn't require authentication
const response = await api.get('/public/settings');
setWatermarkEnabled(response.data.branding_watermark_enabled || false);
} catch (error) {
console.error('Failed to fetch watermark settings:', error);
// Default to false if we can't fetch settings
setWatermarkEnabled(false);
} finally {
setLoading(false);
}
+14 -3
View File
@@ -34,7 +34,8 @@
"upload": "Hochladen",
"days": "Tage",
"customize": "Anpassen",
"hide": "Ausblenden"
"hide": "Ausblenden",
"unknown": "Unbekannt"
},
"upload": {
"photoCategory": "Fotokategorie",
@@ -47,7 +48,10 @@
"uploadComplete": "Upload abgeschlossen!",
"uploadFailed": "Upload fehlgeschlagen",
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden",
"uploadPhotos": "Fotos hochladen"
"uploadPhotos": "Fotos hochladen",
"maxFilesReached": "Maximal 500 Dateien erlaubt",
"someFilesSkipped": "Einige Dateien wurden übersprungen (500 Dateien Limit)",
"tooManyFiles": "Maximal 500 Dateien können gleichzeitig hochgeladen werden"
},
"navigation": {
"dashboard": "Dashboard",
@@ -261,6 +265,7 @@
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
"securityAccess": "Sicherheit & Zugriff",
"galleryPassword": "Galerie-Passwort",
"passwordHelperText": "Sie können Datumsangaben wie \"04.07.2025\" oder beliebigen Text mit mindestens 6 Zeichen verwenden",
"passwordPlaceholder": "Sicheres Passwort eingeben",
"confirmPassword": "Passwort bestätigen",
"showPasswords": "Passwörter anzeigen",
@@ -360,7 +365,13 @@
"eventsSelected_plural": "{{count}} Veranstaltungen ausgewählt",
"bulkArchive": "Archivieren",
"confirmBulkArchive": "Sind Sie sicher, dass Sie {{count}} Veranstaltung(en) archivieren möchten?",
"confirmBulkArchiveDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Archivierte Veranstaltungen sind nicht mehr öffentlich zugänglich."
"confirmBulkArchiveDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Archivierte Veranstaltungen sind nicht mehr öffentlich zugänglich.",
"stats": {
"totalEvents": "Gesamtveranstaltungen",
"activeEvents": "Aktive Veranstaltungen",
"totalPhotos": "Gesamtfotos",
"expiringEvents": "Bald ablaufend"
}
},
"settings": {
"title": "Systemeinstellungen",
+13 -2
View File
@@ -34,7 +34,8 @@
"upload": "Upload",
"days": "days",
"customize": "Customize",
"hide": "Hide"
"hide": "Hide",
"unknown": "Unknown"
},
"upload": {
"photoCategory": "Photo Category",
@@ -47,7 +48,10 @@
"uploadComplete": "Upload complete!",
"uploadFailed": "Upload failed",
"someFilesFailed": "Some files failed to upload",
"uploadPhotos": "Upload Photos"
"uploadPhotos": "Upload Photos",
"maxFilesReached": "Maximum 500 files allowed",
"someFilesSkipped": "Some files were skipped (500 file limit)",
"tooManyFiles": "Maximum 500 files can be uploaded at once"
},
"navigation": {
"dashboard": "Dashboard",
@@ -279,6 +283,7 @@
"adminEmailHelp": "Will receive system notifications and archive confirmations",
"securityAccess": "Security & Access",
"galleryPassword": "Gallery Password",
"passwordHelperText": "You can use dates like \"04.07.2025\" or any text with 6+ characters",
"confirmPassword": "Confirm Password",
"showPasswords": "Show passwords",
"gallerySettings": "Gallery Settings",
@@ -337,6 +342,12 @@
"expires": "Expires",
"actions": "Actions",
"noEventsFound": "No events found",
"stats": {
"totalEvents": "Total Events",
"activeEvents": "Active Events",
"totalPhotos": "Total Photos",
"expiringEvents": "Expiring Soon"
},
"viewDetails": "View Details",
"archiveEventAction": "Archive Event",
"downloadArchiveAction": "Download Archive",
+4 -1
View File
@@ -169,7 +169,10 @@ export const ArchivesPage: React.FC = () => {
<div>
<p className="text-sm text-neutral-600">{t('archives.totalPhotos')}</p>
<p className="text-2xl font-bold text-neutral-900">
{archives.reduce((sum, a) => sum + a.photoCount, 0).toLocaleString()}
{(() => {
const total = archives.reduce((sum, a) => sum + (parseInt(String(a.photoCount)) || 0), 0);
return total === 0 ? '0' : total.toLocaleString();
})()}
</p>
</div>
<FileArchive className="w-8 h-8 text-green-600" />
@@ -204,6 +204,9 @@ export const CreateEventPage: React.FC = () => {
newErrors.password = t('validation.passwordRequired');
} else if (formData.password.length < 6) {
newErrors.password = t('validation.passwordMinLength');
} else if (/^\d{1,6}$/.test(formData.password)) {
// Prevent simple numeric passwords like "123456"
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
}
if (formData.password !== formData.confirm_password) {
@@ -412,6 +415,7 @@ export const CreateEventPage: React.FC = () => {
onChange={handleInputChange('password')}
error={errors.password}
placeholder={t('events.enterPassword')}
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
leftIcon={<Lock className="w-5 h-5 text-neutral-400" />}
className="pr-10"
/>
@@ -180,6 +180,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
newErrors.password = t('validation.passwordRequired');
} else if (formData.password.length < 6) {
newErrors.password = t('validation.passwordMinLength');
} else if (/^\d{1,6}$/.test(formData.password)) {
// Prevent simple numeric passwords like "123456"
newErrors.password = t('validation.passwordTooSimple', 'Password cannot be just numbers. Consider using a date format like "04.07.2025"');
}
if (formData.password !== formData.confirm_password) {
@@ -309,7 +312,6 @@ export const CreateEventPageEnhanced: React.FC = () => {
value={formData.event_date}
onChange={handleInputChange('event_date')}
error={errors.event_date}
min={format(new Date(), 'yyyy-MM-dd')}
leftIcon={<Calendar className="w-5 h-5" />}
/>
</div>
@@ -454,6 +456,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
value={formData.password}
onChange={handleInputChange('password')}
error={errors.password}
helperText={t('events.passwordHelperText', 'You can use dates like "04.07.2025" or any text with 6+ characters')}
leftIcon={<Lock className="w-5 h-5" />}
rightIcon={
<button
+124 -9
View File
@@ -1,4 +1,4 @@
import React, { useState, useMemo } from 'react';
import React, { useState, useMemo, useEffect, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import {
Plus,
@@ -9,7 +9,11 @@ import {
ExternalLink,
Edit,
Download,
Trash2
Trash2,
Calendar,
Users,
Image,
Activity
} from 'lucide-react';
import { parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify';
@@ -33,12 +37,47 @@ export const EventsListPage: React.FC = () => {
const [selectedEvents, setSelectedEvents] = useState<number[]>([]);
// const [showFilters, setShowFilters] = useState(false);
const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
// Get filter from URL
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | null;
const isExpiringFilter = searchParams.get('filter') === 'expiring';
// Close dropdown when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement;
if (!target.closest('.dropdown-container')) {
setActiveDropdown(null);
setDropdownPosition(null);
}
};
if (activeDropdown !== null) {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}
}, [activeDropdown]);
// Update dropdown position on scroll/resize
useEffect(() => {
const handleScrollOrResize = () => {
if (activeDropdown !== null) {
setActiveDropdown(null);
setDropdownPosition(null);
}
};
window.addEventListener('scroll', handleScrollOrResize, true);
window.addEventListener('resize', handleScrollOrResize);
return () => {
window.removeEventListener('scroll', handleScrollOrResize, true);
window.removeEventListener('resize', handleScrollOrResize);
};
}, [activeDropdown]);
// Fetch events
const { data, isLoading, error } = useQuery({
queryKey: ['admin-events', statusFilter],
@@ -197,6 +236,59 @@ export const EventsListPage: React.FC = () => {
</Button>
</div>
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('events.stats.totalEvents')}</p>
<p className="text-2xl font-bold text-neutral-900">{data?.events.length || 0}</p>
</div>
<Calendar className="w-8 h-8 text-primary-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('events.stats.activeEvents')}</p>
<p className="text-2xl font-bold text-neutral-900">
{data?.events.filter(e => e.is_active && !e.is_archived).length || 0}
</p>
</div>
<Activity className="w-8 h-8 text-green-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('events.stats.totalPhotos')}</p>
<p className="text-2xl font-bold text-neutral-900">
{data?.events.reduce((sum, e) => sum + (e.photo_count || 0), 0) || 0}
</p>
</div>
<Image className="w-8 h-8 text-blue-600" />
</div>
</Card>
<Card padding="sm">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-neutral-600">{t('events.stats.expiringEvents')}</p>
<p className="text-2xl font-bold text-neutral-900">
{data?.events.filter(e => {
if (!e.is_active || e.is_archived) return false;
const days = e.expires_at ? differenceInDays(parseISO(e.expires_at), new Date()) : 0;
return days <= 7 && days > 0;
}).length || 0}
</p>
</div>
<AlertTriangle className="w-8 h-8 text-orange-600" />
</div>
</Card>
</div>
{/* Filters and Search */}
<Card padding="sm" className="mb-6">
<div className="flex flex-col lg:flex-row gap-4">
@@ -272,8 +364,8 @@ export const EventsListPage: React.FC = () => {
</Card>
{/* Events Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<Card className="overflow-visible">
<div className="overflow-x-auto overflow-y-visible">
<table className="w-full">
<thead className="bg-neutral-50 border-b border-neutral-200">
<tr>
@@ -347,21 +439,38 @@ export const EventsListPage: React.FC = () => {
{event.expires_at ? format(parseISO(event.expires_at), 'MMM d, yyyy') : 'N/A'}
</td>
<td className="px-6 py-4 text-right">
<div className="relative inline-block text-left">
<div className="relative inline-block text-left dropdown-container">
<button
onClick={() => setActiveDropdown(activeDropdown === event.id ? null : event.id)}
onClick={(e) => {
e.stopPropagation();
if (activeDropdown === event.id) {
setActiveDropdown(null);
setDropdownPosition(null);
} else {
const rect = e.currentTarget.getBoundingClientRect();
setActiveDropdown(event.id);
setDropdownPosition({
top: rect.bottom + window.scrollY,
left: rect.right - 224 + window.scrollX // 224px = 14rem (w-56)
});
}
}}
className="text-neutral-400 hover:text-neutral-600 p-1"
>
<MoreVertical className="w-5 h-5" />
</button>
{activeDropdown === event.id && (
<div className="absolute right-0 z-10 mt-2 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5">
{activeDropdown === event.id && dropdownPosition && (
<div
className="fixed z-50 w-56 rounded-md shadow-lg bg-white ring-1 ring-black ring-opacity-5"
style={{ top: `${dropdownPosition.top}px`, left: `${dropdownPosition.left}px` }}
>
<div className="py-1">
<button
onClick={() => {
navigate(`/admin/events/${event.id}`);
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
@@ -374,7 +483,10 @@ export const EventsListPage: React.FC = () => {
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
onClick={() => setActiveDropdown(null)}
onClick={() => {
setActiveDropdown(null);
setDropdownPosition(null);
}}
>
<ExternalLink className="w-4 h-4" />
{t('events.viewGallery')}
@@ -385,6 +497,7 @@ export const EventsListPage: React.FC = () => {
onClick={() => {
archiveMutation.mutate(event.id);
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
@@ -397,6 +510,7 @@ export const EventsListPage: React.FC = () => {
onClick={() => {
toast.info(t('events.downloadArchiveSoon'));
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
>
@@ -409,6 +523,7 @@ export const EventsListPage: React.FC = () => {
if (confirm(t('events.deleteEventConfirm'))) {
deleteMutation.mutate(event.id);
setActiveDropdown(null);
setDropdownPosition(null);
}
}}
className="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 flex items-center gap-2"
+8 -3
View File
@@ -47,6 +47,11 @@ export interface SystemStatus {
activityLogs: number;
};
};
storage: {
totalUsed: number;
photoStorage: number;
archiveStorage: number;
};
emailQueue: {
pending: number;
sent: number;
@@ -100,7 +105,7 @@ export const settingsService = {
formData.append('logo', file);
const response = await api.post<{ logoUrl: string }>(
'/api/admin/settings/logo',
'/admin/settings/logo',
formData,
{
headers: {
@@ -118,7 +123,7 @@ export const settingsService = {
formData.append('favicon', file);
const response = await api.post<{ faviconUrl: string }>(
'/api/admin/settings/favicon',
'/admin/settings/favicon',
formData,
{
headers: {
@@ -136,7 +141,7 @@ export const settingsService = {
formData.append('watermarkLogo', file);
const response = await api.post<{ watermarkLogoUrl: string }>(
'/api/admin/settings/branding/watermark-logo',
'/admin/settings/branding/watermark-logo',
formData,
{
headers: {
+1
View File
@@ -10,6 +10,7 @@
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"verbatimModuleSyntax": false,
"moduleDetection": "force",
"noEmit": true,
-380
View File
@@ -1,380 +0,0 @@
#!/bin/bash
# Setup script to create remaining files
echo "Creating remaining project files..."
# Create directories
mkdir -p backend/src/services
mkdir -p backend/src/utils
mkdir -p backend/src/routes
mkdir -p backend/migrations
mkdir -p backend/scripts
mkdir -p backend/__tests__
mkdir -p frontend/public
mkdir -p frontend/src/components
mkdir -p frontend/src/contexts
mkdir -p frontend/src/hooks
mkdir -p frontend/src/pages/admin
mkdir -p frontend/src/services
mkdir -p frontend/src/config
mkdir -p nginx/sites-enabled
mkdir -p scripts
mkdir -p storage/events/active
mkdir -p storage/events/archived
mkdir -p storage/thumbnails
mkdir -p data
mkdir -p logs
mkdir -p certbot/conf
mkdir -p certbot/www
# Create .gitkeep files
touch storage/events/active/.gitkeep
touch storage/events/archived/.gitkeep
touch storage/thumbnails/.gitkeep
touch data/.gitkeep
touch logs/.gitkeep
# Create remaining backend services
cat > backend/src/services/imageProcessor.js << 'EOF'
const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const THUMBNAIL_WIDTH = 300;
const THUMBNAIL_PATH = path.join(__dirname, '../../../storage/thumbnails');
async function generateThumbnail(imagePath) {
const filename = path.basename(imagePath);
const thumbnailFilename = `thumb_${filename}`;
const thumbnailPath = path.join(THUMBNAIL_PATH, thumbnailFilename);
// Ensure thumbnail directory exists
await fs.mkdir(THUMBNAIL_PATH, { recursive: true });
// Generate thumbnail
await sharp(imagePath)
.resize(THUMBNAIL_WIDTH, null, {
withoutEnlargement: true,
fit: 'inside'
})
.jpeg({ quality: 80 })
.toFile(thumbnailPath);
return path.relative(path.join(__dirname, '../../../storage'), thumbnailPath);
}
module.exports = { generateThumbnail };
EOF
# Create logger utility
cat > backend/src/utils/logger.js << 'EOF'
const winston = require('winston');
const path = require('path');
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
transports: [
new winston.transports.File({
filename: path.join(__dirname, '../../../logs/error.log'),
level: 'error'
}),
new winston.transports.File({
filename: path.join(__dirname, '../../../logs/combined.log')
})
]
});
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
)
}));
}
module.exports = logger;
EOF
echo "Backend services created."
# Create migration init script
cat > backend/migrations/init.js << 'EOF'
const bcrypt = require('bcrypt');
const { db, initializeDatabase } = require('../src/database/db');
async function runMigrations() {
console.log('Running database migrations...');
try {
// Initialize tables
await initializeDatabase();
// Create default admin user if none exists
const adminExists = await db('admin_users').first();
if (!adminExists) {
const defaultPassword = 'admin123'; // Change this!
const passwordHash = await bcrypt.hash(defaultPassword, 10);
await db('admin_users').insert({
username: 'admin',
email: 'admin@example.com',
password_hash: passwordHash
});
console.log('Default admin user created:');
console.log('Username: admin');
console.log('Password: admin123');
console.log('⚠️ Please change this password immediately!');
}
console.log('Migrations completed successfully');
process.exit(0);
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
}
}
runMigrations();
EOF
echo "Migration script created."
# Create README
cat > README.md << 'EOF'
# Photo Sharing Platform
A secure, self-hosted photo sharing platform designed for weddings and events. Features automatic expiration, email notifications, and simple file-based management.
## Features
- 🔒 Password Protected Galleries
- ⏰ Automatic Expiration
- 📧 Email Notifications
- 📁 Simple File Management
- 📊 Analytics Integration
- 🎨 Customizable Themes
- 📱 Mobile Responsive
- ⚡ Docker Ready
## Quick Start
1. Clone the repository
2. Run `./scripts/install.sh`
3. Configure `.env` file
4. Setup SSL: `./scripts/setup-ssl.sh`
5. Start: `docker-compose -f docker-compose.prod.yml up -d`
Default credentials: admin / admin123 (change immediately!)
## Documentation
See DEPLOYMENT.md for detailed deployment instructions.
## License
MIT License
EOF
echo "README created."
# Create main installation script
cat > scripts/install.sh << 'EOF'
#!/bin/bash
set -e
echo "Photo Sharing Platform - Docker Installation"
echo "==========================================="
# Check if running as root
if [[ $EUID -ne 0 ]]; then
echo "This script must be run as root"
exit 1
fi
# Function to check if command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Check prerequisites
echo "Checking prerequisites..."
# Install Docker if not present
if ! command_exists docker; then
echo "Installing Docker..."
curl -fsSL https://get.docker.com -o get-docker.sh
sh get-docker.sh
rm get-docker.sh
fi
# Install Docker Compose if not present
if ! command_exists docker-compose; then
echo "Installing Docker Compose..."
curl -L "https://github.com/docker/compose/releases/latest/download/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
chmod +x /usr/local/bin/docker-compose
fi
# Create necessary directories
echo "Creating directory structure..."
mkdir -p storage/events/{active,archived}
mkdir -p storage/thumbnails
mkdir -p data
mkdir -p logs
mkdir -p nginx/sites-enabled
mkdir -p certbot/{conf,www}
# Set permissions
chmod -R 755 storage
chmod -R 755 data
chmod -R 755 logs
# Copy environment file
if [ ! -f .env ]; then
cp .env.example .env
echo "Created .env file. Please edit it with your configuration."
fi
# Generate secure passwords
echo "Generating secure passwords..."
JWT_SECRET=$(openssl rand -base64 32)
DB_PASSWORD=$(openssl rand -base64 32)
UMAMI_HASH_SALT=$(openssl rand -base64 32)
# Update .env file with generated values
sed -i "s/JWT_SECRET=.*/JWT_SECRET=$JWT_SECRET/" .env
sed -i "s/DB_PASSWORD=.*/DB_PASSWORD=$DB_PASSWORD/" .env
sed -i "s/UMAMI_HASH_SALT=.*/UMAMI_HASH_SALT=$UMAMI_HASH_SALT/" .env
echo ""
echo "Installation complete!"
echo "Next steps:"
echo "1. Edit .env file with your domain names and SMTP settings"
echo "2. Run: ./scripts/setup-ssl.sh to configure SSL certificates"
echo "3. Run: docker-compose -f docker-compose.prod.yml up -d"
echo "4. Run: docker-compose -f docker-compose.prod.yml exec backend npm run migrate"
EOF
chmod +x scripts/install.sh
echo "Installation script created."
# Create nginx config
cat > nginx/nginx.conf << 'EOF'
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
# Rate limiting
limit_req_zone $binary_remote_addr zone=general:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=auth:10m rate=5r/m;
include /etc/nginx/sites-enabled/*.conf;
}
EOF
echo "Nginx config created."
# Create frontend package.json
cat > frontend/package.json << 'EOF'
{
"name": "photo-sharing-frontend",
"version": "1.0.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.8.0",
"axios": "^1.3.2",
"react-query": "^3.39.3",
"date-fns": "^2.29.3",
"react-toastify": "^9.1.1",
"react-dropzone": "^14.2.3",
"react-image-gallery": "^1.2.11",
"react-countdown": "^2.3.5",
"tailwindcss": "^3.2.4",
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"react-scripts": "5.0.1"
},
"proxy": "http://localhost:3000"
}
EOF
echo "Frontend package.json created."
echo ""
echo "Setup script complete!"
echo "Most important files have been created."
echo ""
echo "To complete the setup:"
echo "1. Run this script: chmod +x setup-remaining-files.sh && ./setup-remaining-files.sh"
echo "2. Review and update the created files as needed"
echo "3. Install dependencies: cd backend && npm install && cd ../frontend && npm install"
echo "4. Follow the deployment instructions in the README"
-111
View File
@@ -1,111 +0,0 @@
#!/bin/bash
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${GREEN}🚀 Photo Sharing Platform - Local Development Setup${NC}"
echo "=================================================="
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo -e "${RED}❌ Docker is not installed. Please install Docker Desktop first.${NC}"
echo " Visit: https://www.docker.com/products/docker-desktop"
exit 1
fi
# Check if Docker is running
if ! docker info &> /dev/null; then
echo -e "${RED}❌ Docker is not running. Please start Docker Desktop.${NC}"
exit 1
fi
# Create necessary directories
echo -e "${YELLOW}📁 Creating directories...${NC}"
mkdir -p storage/events/{active,archived}
mkdir -p storage/thumbnails
mkdir -p data
mkdir -p logs
mkdir -p backend/node_modules
mkdir -p frontend/node_modules
# Copy local environment file if it doesn't exist
if [ ! -f .env ]; then
echo -e "${YELLOW}📋 Setting up environment...${NC}"
cp .env.local .env
fi
# Stop any existing containers
echo -e "${YELLOW}🛑 Stopping existing containers...${NC}"
docker-compose -f docker-compose.local.yml down 2>/dev/null || true
# Build images
echo -e "${YELLOW}🔨 Building Docker images...${NC}"
docker-compose -f docker-compose.local.yml build
# Start services
echo -e "${YELLOW}🚀 Starting services...${NC}"
docker-compose -f docker-compose.local.yml up -d
# Wait for backend to be ready
echo -e "${YELLOW}⏳ Waiting for backend to start...${NC}"
max_attempts=30
attempt=1
while [ $attempt -le $max_attempts ]; do
if curl -s http://localhost:3001/api/health > /dev/null 2>&1; then
echo -e "${GREEN}✅ Backend is ready!${NC}"
break
fi
echo -n "."
sleep 2
attempt=$((attempt + 1))
done
if [ $attempt -gt $max_attempts ]; then
echo -e "${RED}❌ Backend failed to start. Check logs with: docker-compose -f docker-compose.local.yml logs backend${NC}"
exit 1
fi
# Build frontend for production-like testing
echo -e "${YELLOW}📦 Building frontend...${NC}"
docker-compose -f docker-compose.local.yml exec frontend-dev npm run build
# Show status
echo ""
echo -e "${GREEN}✅ Local development environment is ready!${NC}"
echo ""
echo -e "${GREEN}🌐 Access Points:${NC}"
echo " Frontend (Production Build): http://localhost:3000"
echo " Frontend (Dev with Hot Reload): http://localhost:3002"
echo " Backend API: http://localhost:3001/api"
echo " Mailhog (Email Testing): http://localhost:8025"
echo ""
echo -e "${GREEN}🔑 Default Admin Credentials:${NC}"
echo " Username: admin"
echo " Password: admin123"
echo ""
echo -e "${GREEN}📝 Useful Commands:${NC}"
echo " View logs: docker-compose -f docker-compose.local.yml logs -f"
echo " Stop all: ./stop-local.sh"
echo " Backend shell: docker-compose -f docker-compose.local.yml exec backend sh"
echo " Reset database: docker-compose -f docker-compose.local.yml exec backend npm run migrate"
echo ""
echo -e "${GREEN}💡 Tips:${NC}"
echo " - Frontend dev server (port 3002) has hot reload enabled"
echo " - All emails are caught by Mailhog - check http://localhost:8025"
echo " - SQLite database is stored in ./data/photo_sharing.db"
echo " - Upload photos to ./storage/events/active/{event-name}/"
echo ""
# Open browser
if command -v xdg-open &> /dev/null; then
xdg-open http://localhost:3002
elif command -v open &> /dev/null; then
open http://localhost:3002
fi
# Show logs
echo -e "${YELLOW}📋 Showing logs (Ctrl+C to exit)...${NC}"
docker-compose -f docker-compose.local.yml logs -f
-22
View File
@@ -1,22 +0,0 @@
#!/bin/bash
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${YELLOW}🛑 Stopping Photo Sharing Platform...${NC}"
# Stop all containers
docker-compose -f docker-compose.local.yml down
# Optional: Remove volumes (uncomment if you want to reset data)
# docker-compose -f docker-compose.local.yml down -v
echo -e "${GREEN}✅ All services stopped${NC}"
echo ""
echo -e "${YELLOW}💡 Tips:${NC}"
echo " - Your data is preserved in ./data and ./storage"
echo " - To completely reset, run: docker-compose -f docker-compose.local.yml down -v"
echo " - To restart, run: ./start-local.sh"
-94
View File
@@ -1,94 +0,0 @@
#!/bin/bash
# Colors for output
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color
echo -e "${GREEN}🔄 Updating Photo Sharing Platform - Local Development${NC}"
echo "===================================================="
# Check if Docker is running
if ! docker info &> /dev/null; then
echo -e "${RED}❌ Docker is not running. Please start Docker Desktop.${NC}"
exit 1
fi
# Stop all containers
echo -e "${YELLOW}🛑 Stopping all containers...${NC}"
docker-compose -f docker-compose.local.yml down
# Remove old images to force rebuild
echo -e "${YELLOW}🗑️ Removing old images...${NC}"
docker-compose -f docker-compose.local.yml rm -f
# Pull latest base images
echo -e "${YELLOW}📥 Pulling latest base images...${NC}"
docker-compose -f docker-compose.local.yml pull
# Build frontend production files
echo -e "${YELLOW}📦 Building frontend production files...${NC}"
cd frontend
npm install --legacy-peer-deps
npm run build
cd ..
# Rebuild all images with no cache
echo -e "${YELLOW}🔨 Rebuilding Docker images (no cache)...${NC}"
docker-compose -f docker-compose.local.yml build --no-cache
# Start all services
echo -e "${YELLOW}🚀 Starting services...${NC}"
docker-compose -f docker-compose.local.yml up -d
# Wait for backend to be ready
echo -e "${YELLOW}⏳ Waiting for backend to start...${NC}"
max_attempts=30
attempt=1
while [ $attempt -le $max_attempts ]; do
if curl -s http://localhost:3001/api/health > /dev/null 2>&1; then
echo -e "${GREEN}✅ Backend is ready!${NC}"
break
fi
echo -n "."
sleep 2
attempt=$((attempt + 1))
done
if [ $attempt -gt $max_attempts ]; then
echo -e "${RED}❌ Backend failed to start. Check logs with: docker-compose -f docker-compose.local.yml logs backend${NC}"
exit 1
fi
# Wait a bit more for frontend to be ready
echo -e "${YELLOW}⏳ Waiting for frontend to be ready...${NC}"
sleep 5
# Show status
echo ""
echo -e "${GREEN}✅ Local development environment has been updated!${NC}"
echo ""
echo -e "${GREEN}🌐 Access Points:${NC}"
echo " Frontend (Nginx): http://localhost:3005"
echo " Frontend (Dev): http://localhost:3002"
echo " Backend API: http://localhost:3001"
echo " Mailhog: http://localhost:8025"
echo ""
echo -e "${GREEN}📝 Container Status:${NC}"
docker-compose -f docker-compose.local.yml ps
echo ""
echo -e "${GREEN}💡 Tips:${NC}"
echo " - View logs: docker-compose -f docker-compose.local.yml logs -f"
echo " - View specific service logs: docker-compose -f docker-compose.local.yml logs -f [service-name]"
echo " - Stop all: ./stop-local.sh"
echo ""
# Open browser
if command -v xdg-open &> /dev/null; then
xdg-open http://localhost:3005
elif command -v open &> /dev/null; then
open http://localhost:3005
fi
echo -e "${GREEN}✨ Update complete! The browser should open automatically.${NC}"