Compare commits

..

98 Commits

Author SHA1 Message Date
Gitea Actions Bot e229c60b22 chore: bump version to 1.0.68 (backend + frontend)
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-18 20:51:20 +00:00
paul 4966bc6a58 fix: resolve database connection error for analytics settings
Mirror to GitHub / mirror (push) Successful in 20s
Test and Lint / backend-test (push) Successful in 1m10s
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
- Update publicSettings.js to handle missing analytics setting_type gracefully
- Add dedicated PUT /analytics endpoint for saving analytics settings
- Update frontend settings service to route to correct endpoints based on setting type
- Fix query to use WHERE clause that won't fail if analytics type doesn't exist

This fixes the "Connection terminated unexpectedly" error when fetching
public settings with analytics configuration.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-18 22:46:41 +02:00
Gitea Actions Bot 617f292516 chore: bump version to 1.0.67 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-18 20:35:03 +00:00
paul 8e95004022 feat: fix analytics dashboard and implement complete Umami integration
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m21s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m24s
Version and Release / version-bump (push) Successful in 1m1s
Version and Release / trigger-drone (push) Successful in 4s
- Fix backend analytics to include both 'download' and 'download_all' actions
- Add Analytics tab to Settings page for Umami configuration
- Update public settings endpoint to expose Umami config when enabled
- Implement dynamic Umami initialization from backend settings
- Fix frontend analytics calculations (remove hardcoded estimations)
- Add proper download counts and unique visitor tracking
- Update CLAUDE.md with production safety guidelines

The analytics dashboard now shows accurate data for all metrics, and Umami
can be configured through the admin panel instead of environment variables.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-18 22:29:09 +02:00
Gitea Actions Bot 93df328853 chore: bump backend version to 1.0.66
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-18 18:47:07 +00:00
paul 657e74a2e3 fix: resolve duplicate logger declaration and syntax error in rate limit service
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m8s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m28s
Version and Release / version-bump (push) Successful in 40s
Version and Release / trigger-drone (push) Successful in 3s
- Remove duplicate logger import in server.js (line 26)
- Fix missing closing bracket in rateLimitService.js headers object
- Ensure backend starts without syntax errors

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-18 20:41:59 +02:00
Gitea Actions Bot ea77f7917e chore: bump version to 1.0.65 (backend + frontend)
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-18 17:31:04 +00:00
paul f6e5a454ae feat: enhance security logging and ensure rate limit blocks are properly tracked
Mirror to GitHub / mirror (push) Successful in 31s
Test and Lint / backend-test (push) Successful in 1m27s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m22s
Version and Release / version-bump (push) Successful in 38s
Version and Release / trigger-drone (push) Successful in 3s
- Add comprehensive logging for rate limit blocks with full request details
  - IP address (with proper proxy detection), user agent, headers, timestamps
  - Rate limit info (current count, limit, remaining, reset time)
  - Separate tracking for auth vs general endpoints

- Enhance authentication failure logging
  - JWT validation failures with detailed error info
  - Admin auth attempts without token
  - Failed token validation with user context
  - All events include IP, path, method, user agent

- Improve Winston logger configuration for production
  - Add automatic log rotation (10MB errors, 50MB combined)
  - Create separate security.log for auth/rate limit events
  - Ensure logs directory exists automatically
  - Add structured JSON format for log aggregation
  - Support container logging with LOG_TO_CONSOLE env var

- Create comprehensive documentation
  - Security logging guide with examples
  - Monitoring recommendations
  - Configuration reference

- Add test script to verify logging functionality

All rate limit settings remain configurable via admin panel:
- Window duration, max requests, auth limits
- Skip authenticated requests option
- Public endpoints only option

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-18 19:25:42 +02:00
Gitea Actions Bot cc7ad4b2bc chore: bump frontend version to 1.0.61
continuous-integration/drone/push Build is passing
2025-07-17 09:20:40 +00:00
paul 827a599102 feat: enhance CMS editor with rich text features and fix line break preservation
Mirror to GitHub / mirror (push) Successful in 22s
Test and Lint / backend-test (push) Successful in 1m16s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m19s
Version and Release / version-bump (push) Failing after 38s
Version and Release / trigger-drone (push) Has been skipped
- Fix line break preservation with proper HardBreak extension configuration
- Add text alignment support (left, center, right, justify) for paragraphs and headings
- Add H4-H6 heading support with keyboard shortcuts
- Add code block support with syntax highlighting for multiple languages
- Enhance preview functionality with split-pane view (Edit/Preview/Split modes)
- Add custom prose CSS overrides for proper content rendering
- Update DOMPurify configuration to preserve br tags and text alignment
- Add visual indicators and help modal with all keyboard shortcuts
- Improve toolbar organization with grouped buttons and separators
- Add auto-save status indicator and word/character count

Fixes issues with:
- Line breaks not showing in gallery view
- Limited heading options (only H1-H3)
- No text formatting capabilities
- Missing preview functionality

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-17 11:15:41 +02:00
Gitea Actions Bot b9841b762c chore: bump backend version to 1.0.64
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-17 08:08:59 +00:00
paul e636cf5d56 fix: prevent photo corruption during upload and add batch processing improvements
Mirror to GitHub / mirror (push) Successful in 20s
Test and Lint / backend-test (push) Successful in 1m9s
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
- Remove async functions from multer callbacks (primary corruption cause)
- Implement temp directory upload approach with proper cleanup
- Add comprehensive file integrity validation before processing
- Fix batch upload category assignment and photo naming
- Add automatic cleanup service for orphaned temp uploads
- Enhance error handling with better corruption detection
- Add fix-temp-photos script to repair existing temporary files
- Update file watcher to ignore temp upload files

Fixes issues with:
- Corrupted photos showing only partial images
- Photos retaining temp_ names after upload
- Category assignments lost during batch uploads
- Incomplete file uploads causing "Premature end of input" errors

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-17 10:04:29 +02:00
Gitea Actions Bot a896fa66c0 chore: bump frontend version to 1.0.58
continuous-integration/drone/push Build is passing
2025-07-17 07:42:08 +00:00
paul 027c1090a4 fix: remove console.log statements from gallery and auth pages
Mirror to GitHub / mirror (push) Successful in 23s
Test and Lint / backend-test (push) Successful in 1m35s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m29s
Version and Release / version-bump (push) Failing after 52s
Version and Release / trigger-drone (push) Has been skipped
- Remove debug logging from GalleryView component
- Remove console.error statements from auth contexts
- Clean up image loading error logs
- Replace console statements with comments for production security

No sensitive information is now logged to console in production.
2025-07-17 09:33:38 +02:00
Gitea Actions Bot 4e214588a7 chore: bump backend version to 1.0.62
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-17 06:50:13 +00:00
paul 9d0607f4f0 feat: implement dynamic rate limiting with database configuration
Mirror to GitHub / mirror (push) Successful in 21s
Test and Lint / backend-test (push) Successful in 1m10s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 3s
- Add database migration for rate limit settings
- Create rate limit service with dynamic configuration from database
- Implement proper authentication detection for admin and gallery tokens
- Skip rate limiting for authenticated users (configurable)
- Add admin API endpoint to update rate limit settings
- Use correct client IP detection with proxy support
- Cache settings for performance (1 minute cache)
- Default to 1000 requests per 15 minutes for better UX
- Apply auth-specific limits only to login endpoints

Key improvements:
- No more rate limiting for authenticated gallery/admin users
- Configurable via admin settings page
- Immediate effect when settings change
- Better handling of proxied requests

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-17 08:43:44 +02:00
Gitea Actions Bot 8cbe97d2f4 chore: bump backend version to 1.0.61
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-17 05:32:14 +00:00
paul 0d31c9037c fix: handle broken thumbnails and incomplete uploads
Mirror to GitHub / mirror (push) Successful in 21s
Test and Lint / backend-test (push) Successful in 1m4s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Successful in 3s
- Implement automatic thumbnail regeneration for corrupted files
- Add validation middleware to check uploaded files before processing
- Improve error handling for "Premature end of input file" errors
- Delete and regenerate thumbnails when serving fails
- Add file integrity checks during upload process
- Better error reporting for failed uploads

This fixes the VipsJpeg errors and ensures reliable thumbnail generation.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-17 07:25:29 +02:00
Gitea Actions Bot ffcfd9766d chore: bump backend version to 1.0.60
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-16 21:41:10 +00:00
paul 3501a52f0e fix: rate limiting issues with reverse proxy setup
Mirror to GitHub / mirror (push) Successful in 27s
Test and Lint / backend-test (push) Successful in 1m12s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m13s
Version and Release / version-bump (push) Successful in 40s
Version and Release / trigger-drone (push) Successful in 3s
- Add keyGenerator function to properly detect client IP behind proxy
- Support X-Forwarded-For and X-Real-IP headers from Traefik/nginx
- Add custom handlers with better error messages
- Add debug endpoint (dev only) to verify IP detection
- Improve logging for rate limit debugging

This fixes the 429 errors when multiple requests come from same proxy IP.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 23:36:04 +02:00
Gitea Actions Bot 5ca598b80a chore: bump backend version to 1.0.59
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-16 21:19:09 +00:00
paul 4e2075c638 fix: prevent double date formatting in email templates
Mirror to GitHub / mirror (push) Successful in 25s
Test and Lint / backend-test (push) Successful in 1m7s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Successful in 3s
- Remove pre-formatting of dates before passing to email processor
- Let email processor handle all date formatting based on recipient language
- Fix Invalid Date errors by passing raw date values instead of formatted strings
- Remove unused formatDate imports and variables

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 23:14:32 +02:00
Gitea Actions Bot 0a691d4251 chore: bump backend version to 1.0.58
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-16 20:52:25 +00:00
paul 34846ae71a fix: resolve Invalid Date issue in email templates
Mirror to GitHub / mirror (push) Successful in 20s
Test and Lint / backend-test (push) Successful in 1m0s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m12s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Successful in 3s
- Fix date parsing for YYYY-MM-DD format to use local timezone
- Handle date strings properly to avoid timezone conversion issues
- Add validation to ensure dates are valid before formatting
- Update expiration date calculation to use consistent parsing

This fixes the "Invalid Date" display in gallery creation emails.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 22:43:33 +02:00
Gitea Actions Bot ea261dd03b chore: bump version to 1.0.57 (backend + frontend)
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-16 15:37:55 +00:00
paul eb93223d79 fix: email resend language and password display issues
Mirror to GitHub / mirror (push) Successful in 22s
Test and Lint / backend-test (push) Successful in 1m4s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m9s
Version and Release / version-bump (push) Successful in 41s
Version and Release / trigger-drone (push) Successful in 3s
- Added missing translation for admin.activities.email_resent in both EN and DE
- Fixed language detection in resend email to use email processor's priority system
- Fixed password security message showing German text in English emails
- Updated email processor to handle password placeholder and format dates properly
- Dates are now formatted according to language and system settings
- Password security message now displays in the correct language based on recipient
2025-07-16 17:33:33 +02:00
Gitea Actions Bot b20f9cc108 chore: bump backend version to 1.0.56
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-16 15:22:56 +00:00
paul 515814e1d5 feat: update English email templates to match German quality and content
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 2m17s
Version and Release / version-bump (push) Successful in 39s
Version and Release / trigger-drone (push) Successful in 3s
- Enhanced all English email templates with proper HTML styling
- Added conditional content blocks (Handlebars {{#if}} syntax)
- Added styled alert boxes for warnings and success messages
- Added action buttons with proper styling
- Added emoji icons for better visual communication
- Added missing variables: support_email, support_phone, archive_date, photo_count
- Matched professional tone and formatting from German templates
- Improved visual hierarchy with background colors and borders
- All templates now have equal or better quality scores
2025-07-16 17:18:19 +02:00
Gitea Actions Bot fba9838e21 chore: bump backend version to 1.0.55
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-16 14:52:07 +00:00
paul a9c2761986 fix: add language detection from app_settings and fix activity_logs schema
Mirror to GitHub / mirror (push) Successful in 26s
Test and Lint / backend-test (push) Successful in 1m6s
Test and Lint / frontend-test (push) Successful in 2m23s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Successful in 3s
continuous-integration/drone/push Build is passing
- Enhanced language detection priority in email processor:
  1. Event-specific language
  2. App settings general_default_language (NEW)
  3. Email config default_language
  4. Domain-based detection
- Fixed activity_logs insertion error by using logActivity function
- Fixed 500 error on resend email endpoint
- Emails now respect language selected in settings page
2025-07-16 16:47:04 +02:00
Gitea Actions Bot db7e5913eb chore: bump backend version to 1.0.54
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-16 14:44:54 +00:00
paul aa27d1ea79 fix: resolve 500 error on resend email endpoint
Mirror to GitHub / mirror (push) Successful in 31s
Test and Lint / backend-test (push) Successful in 1m16s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m16s
Version and Release / version-bump (push) Successful in 35s
Version and Release / trigger-drone (push) Successful in 3s
- Moved queueEmail import to top of file (no more dynamic require)
- Fixed similar issue in password reset endpoint
- Added error handling for activity log insertion to prevent failures
- Added default values for ip_address and user_agent
- Added detailed error logging for debugging

The email was being sent successfully but the endpoint was returning 500
due to the dynamic require pattern. This is now fixed.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 16:39:56 +02:00
Gitea Actions Bot cf8df2780e chore: bump version to 1.0.53 (backend + frontend)
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-16 14:29:36 +00:00
paul 419a283c62 feat: add resend creation email button and fix Handlebars template processing
Mirror to GitHub / mirror (push) Successful in 21s
Test and Lint / backend-test (push) Successful in 1m6s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m7s
Version and Release / version-bump (push) Successful in 41s
Version and Release / trigger-drone (push) Successful in 3s
- Fixed email template processing to properly handle Handlebars conditionals
  - Installed handlebars package
  - Updated processTemplate to use Handlebars.compile()
  - Now {{#if welcome_message}} blocks work correctly

- Added "Resend Creation Email" button to event details page
  - New endpoint: POST /api/admin/events/:id/resend-email
  - Button appears below "Reset Gallery Password"
  - Shows mail icon and includes success/error notifications
  - For security, resent emails show "(Aus Sicherheitsgründen nicht angezeigt)"
    instead of the actual password

- Added translations:
  - EN: "Resend Creation Email", success/error messages
  - DE: "Erstellungs-E-Mail erneut senden", success/error messages

This fixes the issue where Handlebars syntax was showing in emails and provides
a convenient way to resend the initial gallery creation email to hosts.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 16:25:00 +02:00
Gitea Actions Bot 1b075a4beb chore: bump backend version to 1.0.52
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-16 14:01:47 +00:00
paul 68ff93cf17 feat: complete German email template implementation and language detection
Mirror to GitHub / mirror (push) Successful in 21s
Test and Lint / backend-test (push) Successful in 1m3s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m8s
Version and Release / version-bump (push) Successful in 38s
Version and Release / trigger-drone (push) Successful in 3s
- Added comprehensive German translations for all email templates:
  - gallery_created: Welcome email with proper German formatting
  - expiration_warning: Warning email with countdown in German
  - gallery_expired: Expiration notice in German
  - archive_complete: Archive completion notice in German

- Enhanced language detection with priority system:
  1. Event-specific language setting (new 'language' column)
  2. Global email config default language
  3. Domain-based detection (.de, .at, .ch, .li)

- Added language preferences:
  - events.language column for per-event language settings
  - email_configs.default_language for system-wide default
  - Set default to German ('de') for existing installations

- Fixed email processor to:
  - Pass eventId for proper language detection
  - Handle both SQLite and PostgreSQL JSON formats
  - Log language used for each email sent

All German templates are professionally translated with proper:
- Greetings ("Liebe(r)" instead of "Dear")
- Date formatting (DD.MM.YYYY)
- Professional closings ("Mit freundlichen Grüßen")
- UI elements ("Galerie-Details:" instead of "Gallery Details:")

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 15:57:20 +02:00
Gitea Actions Bot b22a29e877 chore: bump version to 1.0.51 (backend + frontend)
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-16 13:10:04 +00:00
paul e601311ca3 fix: resolve email queue discrepancy between admin dashboard and processor
Mirror to GitHub / mirror (push) Successful in 22s
Test and Lint / backend-test (push) Successful in 1m6s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Version and Release / version-bump (push) Successful in 34s
Version and Release / trigger-drone (push) Successful in 3s
The issue: Admin dashboard showed pending emails that the processor wouldn't
process because they had exceeded retry limits (retry_count >= 3).

Changes:
- Updated backend /admin/system/status to provide detailed email queue stats:
  - pending: total pending emails (as before)
  - processable: emails that will actually be processed (retry_count < 3)
  - stuck: emails that exceeded retry limit but are still pending
- Updated frontend to display stuck emails with warning when present
- Created debug-email-queue.js script to diagnose discrepancies
- Created fix-stuck-emails.js script to handle stuck emails:
  - Can reset retry count, mark as failed, or delete
  - Usage: node fix-stuck-emails.js [reset|fail|delete]

This makes it clear when emails are stuck and won't be processed automatically.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-16 15:05:34 +02:00
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
Gitea Actions Bot 0a21856a8d chore: bump version to 1.0.24
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-14 18:20:14 +00:00
paul 349e7c7eb1 feat: add GitHub issue and PR templates
Mirror to GitHub (Archive Method) / mirror (push) Failing after 16s
Mirror to GitHub (Rsync Method) / mirror (push) Failing after 16s
Mirror to GitHub / mirror (push) Failing after 18s
Test and Lint / backend-test (push) Successful in 1m1s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m24s
Version and Release / version-bump (push) Successful in 33s
Version and Release / trigger-drone (push) Successful in 2s
- Add bug report template with environment details
- Add feature request template with use case section
- Add documentation improvement template
- Add security vulnerability template with warnings
- Add question template for support
- Add issue template config with helpful links
- Add comprehensive pull request template
- Update mirror workflow to exclude production guide

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

This prepares PicPeak as a professional open-source alternative
to PicDrop and Scrapbook.de with clear, simple deployment.
2025-07-14 20:12:14 +02:00
175 changed files with 8563 additions and 6297 deletions
-280
View File
@@ -1,280 +0,0 @@
kind: pipeline
type: docker
name: default
trigger:
branch:
- main
- develop
- feature/*
event:
- push
- pull_request
- tag
volumes:
- name: docker
host:
path: /var/run/docker.sock
steps:
# Frontend Tests
- name: frontend-test
image: node:18-alpine
commands:
- cd frontend
- npm ci --legacy-peer-deps
- npm run lint
- npm run build
when:
event:
- push
- pull_request
# Backend Tests
- name: backend-test
image: node:18-alpine
commands:
- cd backend
- npm ci
- npm run lint
- npm test
environment:
NODE_ENV: test
JWT_SECRET: test-secret
when:
event:
- push
- pull_request
# Build Frontend Docker Image
- name: build-frontend
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/wedding-photo-sharing-frontend
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
- ${DRONE_TAG}
dockerfile: frontend/Dockerfile
context: frontend
registry: registry.local.nothaft.cloud
when:
branch:
- main
event:
- push
- tag
# Build Backend Docker Image
- name: build-backend
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/wedding-photo-sharing-backend
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
- ${DRONE_TAG}
dockerfile: backend/Dockerfile
context: backend
registry: registry.local.nothaft.cloud
when:
branch:
- main
event:
- push
- tag
# Security Scan
- name: security-scan
image: aquasec/trivy:latest
commands:
- trivy image --exit-code 0 --no-progress registry.local.nothaft.cloud/wedding-photo-sharing-frontend:${DRONE_COMMIT_SHA:0:8}
- trivy image --exit-code 0 --no-progress registry.local.nothaft.cloud/wedding-photo-sharing-backend:${DRONE_COMMIT_SHA:0:8}
environment:
DOCKER_HOST: tcp://docker:2375
volumes:
- name: docker
path: /var/run/docker.sock
when:
branch:
- main
event:
- push
# Deploy to Staging
- name: deploy-staging
image: alpine:latest
environment:
SWARM_HOST:
from_secret: staging_swarm_host
SWARM_USER:
from_secret: staging_swarm_user
SWARM_KEY:
from_secret: staging_swarm_key
REGISTRY_URL:
from_secret: docker_registry
VERSION: ${DRONE_COMMIT_SHA:0:8}
commands:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- echo "$SWARM_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -H $SWARM_HOST >> ~/.ssh/known_hosts
- |
ssh $SWARM_USER@$SWARM_HOST << EOF
cd /opt/wedding-photo-sharing
export REGISTRY_URL=registry.local.nothaft.cloud
export VERSION=$VERSION
docker stack deploy -c deploy/docker-stack.yml wedding-photo-sharing
EOF
when:
branch:
- develop
event:
- push
# Deploy to Production
- name: deploy-production
image: alpine:latest
environment:
SWARM_HOST:
from_secret: prod_swarm_host
SWARM_USER:
from_secret: prod_swarm_user
SWARM_KEY:
from_secret: prod_swarm_key
REGISTRY_URL:
from_secret: docker_registry
VERSION: ${DRONE_TAG:-latest}
commands:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- echo "$SWARM_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -H $SWARM_HOST >> ~/.ssh/known_hosts
- |
ssh $SWARM_USER@$SWARM_HOST << EOF
cd /opt/wedding-photo-sharing
export REGISTRY_URL=registry.local.nothaft.cloud
export VERSION=$VERSION
# Backup database before deployment
docker exec \$(docker ps -q -f name=wedding-photo-sharing_db) pg_dump -U postgres wedding_photo_sharing > /backup/db-backup-\$(date +%Y%m%d-%H%M%S).sql
# Deploy stack
docker stack deploy -c deploy/docker-stack.yml wedding-photo-sharing --with-registry-auth
# Wait for services to be ready
sleep 30
# Run migrations if needed
docker exec \$(docker ps -q -f name=wedding-photo-sharing_backend) npm run migrate
EOF
when:
event:
- tag
# Health Check
- name: health-check
image: alpine:latest
commands:
- apk add --no-cache curl
- sleep 30
- curl -f https://${FRONTEND_HOST}/health || exit 1
- curl -f https://${BACKEND_HOST}/api/health || exit 1
when:
branch:
- main
event:
- push
- tag
# Notification - Success
- name: notify-success
image: plugins/slack
settings:
webhook:
from_secret: slack_webhook
channel: deployments
template: |
✅ *Build {{build.number}} succeeded* for {{repo.name}}
Branch: {{build.branch}}
Commit: {{build.commit}}
Author: {{build.author}}
{{#if build.tag}}
🏷️ Tag: {{build.tag}}
🚀 Deployed to *PRODUCTION*
{{else}}
📦 Deployed to *{{build.branch}}*
{{/if}}
🔗 {{build.link}}
when:
status:
- success
# Notification - Failure
- name: notify-failure
image: plugins/slack
settings:
webhook:
from_secret: slack_webhook
channel: deployments
template: |
❌ *Build {{build.number}} failed* for {{repo.name}}
Branch: {{build.branch}}
Commit: {{build.commit}}
Author: {{build.author}}
🔗 {{build.link}}
when:
status:
- failure
---
kind: pipeline
type: docker
name: rollback
trigger:
event:
- rollback
steps:
- name: rollback-production
image: alpine:latest
environment:
SWARM_HOST:
from_secret: prod_swarm_host
SWARM_USER:
from_secret: prod_swarm_user
SWARM_KEY:
from_secret: prod_swarm_key
REGISTRY_URL:
from_secret: docker_registry
commands:
- apk add --no-cache openssh-client
- mkdir -p ~/.ssh
- echo "$SWARM_KEY" > ~/.ssh/id_rsa
- chmod 600 ~/.ssh/id_rsa
- ssh-keyscan -H $SWARM_HOST >> ~/.ssh/known_hosts
- |
ssh $SWARM_USER@$SWARM_HOST << EOF
cd /opt/wedding-photo-sharing
export REGISTRY_URL=registry.local.nothaft.cloud
export VERSION=${DRONE_ROLLBACK_TO}
# Deploy previous version
docker stack deploy -c deploy/docker-stack.yml wedding-photo-sharing --with-registry-auth
EOF
---
kind: secret
name: slack_webhook
get:
path: drone/slack
name: webhook
+35 -23
View File
@@ -1,32 +1,44 @@
# Production Environment Configuration Template
# Copy this file to .env and fill in your values
# PicPeak Production Configuration
# Copy this file to .env and update with your values
# Application URLs
ADMIN_URL=https://yourdomain.com
FRONTEND_URL=https://yourdomain.com
# Required: Security
JWT_SECRET=CHANGE_THIS_TO_RANDOM_32_CHAR_STRING
# Security - CRITICAL: Generate a secure random JWT secret
# You can generate one with: openssl rand -base64 32
JWT_SECRET=your-secure-random-jwt-secret-here
# Required: URLs (update with your domain)
FRONTEND_URL=https://your-domain.com
BACKEND_URL=https://your-domain.com
ADMIN_URL=https://your-domain.com
# Database Configuration (PostgreSQL)
DB_USER=picpeak
DB_PASSWORD=your-secure-database-password
DB_NAME=picpeak
# Email Configuration
# Required: Email Settings
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=true
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
EMAIL_FROM=noreply@yourdomain.com
SMTP_FROM=your-email@gmail.com
# Umami Analytics (Optional)
UMAMI_URL=https://analytics.yourdomain.com
UMAMI_WEBSITE_ID=your-website-id
UMAMI_HASH_SALT=your-random-hash-salt
# Required: Initial Admin Account
ADMIN_EMAIL=admin@your-domain.com
ADMIN_PASSWORD=change-this-password
# First Admin User (for initial setup)
# Run: docker-compose exec backend node scripts/create-admin.js --email admin@yourdomain.com
ADMIN_EMAIL=admin@yourdomain.com
# Database (PostgreSQL recommended for production)
DATABASE_CLIENT=pg
DB_HOST=postgres
DB_PORT=5432
DB_NAME=picpeak
DB_USER=picpeak
DB_PASSWORD=secure-database-password
# Optional: Customization
SITE_NAME=PicPeak
DEFAULT_EXPIRATION_DAYS=30
SESSION_TIMEOUT_MINUTES=60
# Optional: Analytics (Umami)
VITE_UMAMI_URL=
VITE_UMAMI_WEBSITE_ID=
# Advanced: Performance Tuning
NODE_ENV=production
BCRYPT_ROUNDS=12
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
@@ -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
+58 -7
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- main
workflow_dispatch: # Allow manual triggering
jobs:
mirror:
@@ -16,13 +17,25 @@ jobs:
- name: Setup Git
run: |
git config --global user.name "Gitea Mirror Bot"
git config --global user.email "bot@noreply.gitea.local"
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.
@@ -33,16 +46,54 @@ jobs:
git rm -r --cached .claudedocs/ || true
git rm -r --cached backend/data/ || true
git rm -r --cached backend/storage/ || true
git rm -r --cached .gitea/ || true
git rm -r --cached scripts/install-gitea-runner.sh || true
git rm -r --cached .drone* || true
git rm -r --cached .github-mirror-exclude || true
git rm -r --cached .gitattributes-github || true
git rm -r --cached photo-sharing-prd.md || true
git rm -r --cached CLAUDE.md || true
git rm -r --cached PRODUCTION_DEPLOYMENT_GUIDE.md || true
git rm -r --cached logs/ || true
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.GITHUB_TOKEN }}
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:${GITHUB_TOKEN}@github.com/YOUR_GITHUB_USERNAME/YOUR_REPO_NAME.git
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
+5 -1
View File
@@ -17,4 +17,8 @@ node_modules/
dist/
build/
*.log
.DS_Store
.DS_Store
deploy/
certbot/
nginx/
photo-sharing-prd.md
+47
View File
@@ -0,0 +1,47 @@
---
name: Bug report
about: Create a report to help us improve PicPeak
title: '[BUG] '
labels: 'bug'
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS: [e.g. Ubuntu 22.04]
- Browser: [e.g. Chrome 120, Safari 17]
- PicPeak Version: [e.g. 1.0.22]
- Deployment Method: [e.g. Docker Compose, Manual]
- Database: [e.g. PostgreSQL 15, SQLite]
**Logs**
Please include relevant logs:
```
# Backend logs
docker-compose logs backend | tail -50
# Frontend console errors
[paste any browser console errors]
```
**Additional context**
Add any other context about the problem here.
**Possible Solution**
If you have an idea how to fix the issue, please describe it here.
+11
View File
@@ -0,0 +1,11 @@
blank_issues_enabled: false
contact_links:
- name: 📚 Documentation
url: https://github.com/the-luap/picpeak/blob/main/DEPLOYMENT.md
about: Please read the documentation before opening an issue
- name: 💬 Discussions
url: https://github.com/the-luap/picpeak/discussions
about: Ask questions and discuss with the community
- name: 🔒 Security Issues
url: https://github.com/the-luap/picpeak/blob/main/SECURITY.md
about: Please review our security policy for reporting vulnerabilities
+33
View File
@@ -0,0 +1,33 @@
---
name: Documentation
about: Report issues or improvements needed in documentation
title: '[DOCS] '
labels: 'documentation'
assignees: ''
---
**What documentation needs improvement?**
Please specify which document or section needs attention:
- [ ] README.md
- [ ] DEPLOYMENT.md
- [ ] CONTRIBUTING.md
- [ ] API Documentation
- [ ] Code Comments
- [ ] Other: ___________
**Describe the issue**
What's wrong or missing in the documentation?
**Suggested improvement**
How would you improve this documentation?
**Target audience**
Who is this documentation for?
- [ ] New users setting up PicPeak
- [ ] Developers contributing to the project
- [ ] System administrators
- [ ] End users (photographers/clients)
**Additional context**
Add any other context, examples, or references here.
+38
View File
@@ -0,0 +1,38 @@
---
name: Feature request
about: Suggest an idea for PicPeak
title: '[FEATURE] '
labels: 'enhancement'
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Use Case**
Please describe how this feature would be used:
- Who would use it? (photographers, clients, admins)
- When would they use it?
- Why is it important?
**Similar Features**
Are there similar features in:
- PicDrop
- Scrapbook.de
- Other photo sharing platforms
**Mockups or Examples**
If applicable, add mockups, diagrams, or links to similar implementations.
**Additional context**
Add any other context or screenshots about the feature request here.
**Implementation Ideas**
If you have technical ideas about how this could be implemented, please share them.
+26
View File
@@ -0,0 +1,26 @@
---
name: Question
about: Ask a question about PicPeak
title: '[QUESTION] '
labels: 'question'
assignees: ''
---
**Question**
What would you like to know about PicPeak?
**Context**
Please provide context to help us answer your question better:
- What are you trying to achieve?
- What have you already tried?
- Which documentation have you consulted?
**Environment**
If relevant to your question:
- PicPeak Version:
- Deployment Method:
- Operating System:
**Related Issues or Discussions**
Link to any related issues, discussions, or documentation.
@@ -0,0 +1,37 @@
---
name: Security Vulnerability
about: Report security issues privately
title: '[SECURITY] '
labels: 'security'
assignees: ''
---
⚠️ **IMPORTANT: For serious security vulnerabilities, please DO NOT create a public issue.**
Instead, please email security@example.com with the details.
For minor security improvements or questions, you can use this template:
**Type of Security Issue**
- [ ] Authentication/Authorization
- [ ] Data Exposure
- [ ] Input Validation
- [ ] Configuration Issue
- [ ] Dependency Vulnerability
- [ ] Other: ___________
**Description**
Brief description of the security concern.
**Impact**
What could an attacker potentially do?
**Steps to Reproduce**
If applicable, how can this be reproduced?
**Suggested Fix**
If you have ideas on how to fix this issue.
**References**
Any relevant security advisories, CVEs, or documentation.
+49
View File
@@ -0,0 +1,49 @@
## Description
Please include a summary of the changes and which issue is fixed. Include relevant motivation and context.
Fixes # (issue)
## Type of change
Please delete options that are not relevant.
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring
## How Has This Been Tested?
Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce.
- [ ] Unit tests pass (`npm test`)
- [ ] Manual testing completed
- [ ] Tested on Docker deployment
- [ ] Tested on production-like environment
**Test Configuration**:
* PicPeak Version:
* Node.js Version:
* Database: PostgreSQL / SQLite
* Browser:
## Checklist:
- [ ] My code follows the style guidelines of this project
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published
- [ ] I have updated the CHANGELOG.md file
## Screenshots (if appropriate):
## Additional Notes:
Add any additional notes, concerns, or discussion points here.
-108
View File
@@ -1,108 +0,0 @@
name: Create Release
on:
push:
branches:
- main
paths:
- 'frontend/package.json'
- 'backend/package.json'
jobs:
check-version-change:
runs-on: ubuntu-latest
outputs:
version_changed: ${{ steps.check.outputs.changed }}
new_version: ${{ steps.check.outputs.version }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check if version changed
id: check
run: |
# Get current versions
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version")
BACKEND_VERSION=$(node -p "require('./backend/package.json').version")
# Get previous versions
git checkout HEAD~1
PREV_FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "0.0.0")
PREV_BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "0.0.0")
# Check if versions changed
if [[ "$FRONTEND_VERSION" != "$PREV_FRONTEND_VERSION" ]] || [[ "$BACKEND_VERSION" != "$PREV_BACKEND_VERSION" ]]; then
echo "changed=true" >> $GITHUB_OUTPUT
echo "version=$FRONTEND_VERSION" >> $GITHUB_OUTPUT
else
echo "changed=false" >> $GITHUB_OUTPUT
fi
create-release:
needs: check-version-change
if: needs.check-version-change.outputs.version_changed == 'true'
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Generate Changelog
id: changelog
run: |
# Get commits since last tag
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [[ -z "$LAST_TAG" ]]; then
COMMITS=$(git log --oneline)
else
COMMITS=$(git log ${LAST_TAG}..HEAD --oneline)
fi
# Format changelog
echo "## What's Changed" > changelog.md
echo "" >> changelog.md
# Group commits by type
echo "### Features" >> changelog.md
echo "$COMMITS" | grep -E "^[a-f0-9]+ feat:" | sed 's/^[a-f0-9]+ /- /' >> changelog.md || echo "*No new features*" >> changelog.md
echo "" >> changelog.md
echo "### Bug Fixes" >> changelog.md
echo "$COMMITS" | grep -E "^[a-f0-9]+ fix:" | sed 's/^[a-f0-9]+ /- /' >> changelog.md || echo "*No bug fixes*" >> changelog.md
echo "" >> changelog.md
echo "### Other Changes" >> changelog.md
echo "$COMMITS" | grep -vE "^[a-f0-9]+ (feat|fix):" | sed 's/^[a-f0-9]+ /- /' >> changelog.md || echo "*No other changes*" >> changelog.md
# Save changelog
echo "changelog<<EOF" >> $GITHUB_OUTPUT
cat changelog.md >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ needs.check-version-change.outputs.new_version }}
name: Release v${{ needs.check-version-change.outputs.new_version }}
body: |
## PicPeak v${{ needs.check-version-change.outputs.new_version }}
${{ steps.changelog.outputs.changelog }}
### Docker Images
To use this release with Docker:
```bash
docker pull ghcr.io/${{ github.repository }}/frontend:v${{ needs.check-version-change.outputs.new_version }}
docker pull ghcr.io/${{ github.repository }}/backend:v${{ needs.check-version-change.outputs.new_version }}
```
Or use the `latest` tag for the most recent version.
draft: false
prerelease: false
generate_release_notes: true
-107
View File
@@ -1,107 +0,0 @@
name: Automatic Version Bump
on:
push:
branches:
- main
workflow_dispatch:
inputs:
version_type:
description: 'Version bump type'
required: true
default: 'patch'
type: choice
options:
- patch
- minor
- major
jobs:
version-bump:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Configure Git
run: |
git config --global user.name "GitHub Actions Bot"
git config --global user.email "actions@github.com"
- name: Determine version type
id: version_type
run: |
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
echo "type=${{ github.event.inputs.version_type }}" >> $GITHUB_OUTPUT
else
# Auto-detect version type based on commit message
COMMIT_MSG="${{ github.event.head_commit.message }}"
if [[ "$COMMIT_MSG" == *"BREAKING CHANGE"* ]] || [[ "$COMMIT_MSG" == *"!"* ]]; then
echo "type=major" >> $GITHUB_OUTPUT
elif [[ "$COMMIT_MSG" == *"feat:"* ]] || [[ "$COMMIT_MSG" == *"feat("* ]]; then
echo "type=minor" >> $GITHUB_OUTPUT
else
echo "type=patch" >> $GITHUB_OUTPUT
fi
fi
- name: Bump Frontend Version
id: frontend_version
working-directory: ./frontend
run: |
npm version ${{ steps.version_type.outputs.type }} --no-git-tag-version
NEW_VERSION=$(node -p "require('./package.json').version")
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Bump Backend Version
id: backend_version
working-directory: ./backend
run: |
npm version ${{ steps.version_type.outputs.type }} --no-git-tag-version
NEW_VERSION=$(node -p "require('./package.json').version")
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Update Frontend VersionInfo component
run: |
VERSION=${{ steps.frontend_version.outputs.version }}
sed -i "s/const FRONTEND_VERSION = '[^']*'/const FRONTEND_VERSION = '$VERSION'/" frontend/src/components/admin/VersionInfo.tsx
- name: Create Pull Request
uses: peter-evans/create-pull-request@v5
with:
token: ${{ secrets.GITHUB_TOKEN }}
commit-message: "chore: bump version to ${{ steps.frontend_version.outputs.version }}"
title: "chore: bump version to ${{ steps.frontend_version.outputs.version }}"
body: |
## Version Bump
This PR automatically bumps the version numbers:
- Frontend: `${{ steps.frontend_version.outputs.version }}`
- Backend: `${{ steps.backend_version.outputs.version }}`
### Version Type: ${{ steps.version_type.outputs.type }}
### Files Changed:
- `frontend/package.json`
- `backend/package.json`
- `frontend/src/components/admin/VersionInfo.tsx`
---
*This PR was automatically created by the version bump workflow.*
branch: version-bump-${{ steps.frontend_version.outputs.version }}
delete-branch: true
labels: |
version-bump
automated
-118
View File
@@ -1,118 +0,0 @@
# CI/CD Strategy for PicPeak
## Overview
This document outlines the CI/CD strategy using both Gitea Actions and Drone CI to avoid conflicts and ensure proper versioning.
## Pipeline Flow
### 1. Development & Testing (Gitea Actions)
- **Trigger**: Every push to `main` or `develop` branches
- **File**: `.gitea/workflows/test.yml`
- **Purpose**: Run tests, linting, and basic validation
- **Actions**:
- Backend linting and tests
- Frontend linting and build
- Does NOT build Docker images
### 2. Version Management (Gitea Actions)
- **Trigger**: Push to `main` branch (excluding markdown files)
- **File**: `.gitea/workflows/version-and-release.yml`
- **Purpose**: Automatic version incrementing
- **Actions**:
1. Reads current version from `package.json`
2. Increments patch version (e.g., 1.0.0 → 1.0.1)
3. Updates both backend and frontend `package.json`
4. Commits the version change
5. Creates a git tag (e.g., `v1.0.1`)
6. Pushes changes and tag
### 3. Docker Image Building (Drone CI)
- **Trigger**:
- Push to `main` or `develop` (builds with commit SHA)
- New git tags (builds release versions)
- **File**: `.drone.yml`
- **Purpose**: Build and push Docker images
- **Tags Created**:
- `latest` - Always points to newest build
- `{commit-sha}` - Specific commit version
- `{branch}-latest` - Latest for specific branch
- `v1.0.1` - Specific version (on tag trigger)
## Why This Strategy?
1. **Separation of Concerns**:
- Gitea Actions handles code quality and versioning
- Drone CI handles Docker image building
- No overlap or race conditions
2. **Sequential Execution**:
- Version bump happens first
- Tag creation triggers Drone
- Docker images are built with correct version
3. **Version Consistency**:
- Version in `package.json` matches git tag
- Docker images are tagged with same version
- No manual version management needed
## Setup Requirements
1. **Gitea Actions Runner**: Must be configured and running
2. **Drone CI**: Must be connected to your Gitea instance
3. **Secrets**:
- `GITEA_TOKEN` (optional, for pushing version commits)
- Docker registry credentials in Drone
## Version Numbering
- Format: `MAJOR.MINOR.PATCH` (e.g., 1.0.0)
- Automatic increments: PATCH version only
- Manual increments: Edit `package.json` for MAJOR/MINOR changes
## Usage
1. **Regular Development**:
```bash
git add .
git commit -m "feat: add new feature"
git push origin main
```
- Tests run automatically
- Version bumps to 1.0.1
- Docker images built with v1.0.1 tag
2. **Major/Minor Version Change**:
```bash
# Manually edit package.json files to 2.0.0
git add .
git commit -m "feat!: major release"
git push origin main
```
3. **Skip Version Bump**:
- Add `[skip ci]` to commit message
- Or only change markdown files
## Monitoring
- **Gitea Actions**: Check Actions tab in Gitea
- **Drone CI**: Check Drone dashboard
- **Docker Registry**: Verify images are pushed with correct tags
## Troubleshooting
1. **Version not incrementing**:
- Check Gitea Actions logs
- Ensure runner has push permissions
- Verify no `[skip ci]` in commit message
2. **Docker images not building**:
- Check Drone CI webhook configuration
- Verify Drone can see the repository
- Check Docker registry credentials
3. **Conflicts**:
- Never run both pipelines for same task
- Use branch protection to prevent direct pushes
- Always let automation handle versioning
+68 -1
View File
@@ -39,6 +39,12 @@ docker-compose -f docker-compose.prod.yml up -d # Production deployment
pm2 start ecosystem.config.js # Alternative: PM2 deployment
```
**⚠️ CRITICAL PRODUCTION NOTICE:**
- Production runs on a SEPARATE SERVER - never assume local changes affect production
- ALWAYS request production server details before any troubleshooting
- NO trial-and-error approaches in production - data loss is unacceptable
- Every change must be thoroughly analyzed and tested locally first
## Key Product Requirements (from PRD)
### Core Features
@@ -127,6 +133,41 @@ Background services run as separate processes:
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
## Troubleshooting Guidelines
### Before ANY Production Troubleshooting:
1. **ALWAYS request specific details**:
- Production server URL/IP
- Current error messages/logs
- Recent changes or deployments
- Affected users/galleries
- Time of issue occurrence
2. **Thorough Analysis Required**:
- Use detailed thinking/analysis for EVERY troubleshooting task
- Review all related code before suggesting changes
- Consider all potential side effects
- Never make assumptions about production environment
3. **Safe Troubleshooting Steps**:
- First, reproduce issue in local/dev environment
- Analyze logs without modifying production
- Create detailed action plan before any changes
- Always have rollback strategy ready
- Document every step taken
### Common Issues & Safe Approaches:
- **Email not sending**: Check email_queue table, SMTP settings, service status
- **Photos not loading**: Verify file permissions, storage paths, nginx config
- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs
- **Performance problems**: Analyze with monitoring tools first, never experiment
### Data Safety Rules:
- NEVER delete or modify production data without explicit backup confirmation
- ALWAYS verify backups exist before any data operations
- NO direct database modifications without transaction safety
- Log all actions for audit trail
## Environment Variables
### Backend (.env)
@@ -258,4 +299,30 @@ const { theme, setTheme, setThemeByName } = useTheme();
- Guest satisfaction: >90%
- System uptime: 99.9%
- Email delivery rate: >98%
- Successful archiving: 100%
- Successful archiving: 100%
## Documentation & Development Practices
### Documentation Guidelines:
- **NEVER create new documentation files for simple tasks**
- **ALWAYS update existing documentation (like this CLAUDE.md)**
- Only create new .md files when explicitly requested
- Avoid creating temporary scripts for one-off tasks
### Development Best Practices:
- Test all changes thoroughly in local environment first
- Use version control for all changes
- Keep commits atomic and well-described
- Review impact on all integrated services
- Consider backward compatibility
- Update tests when changing functionality
### Production Deployment Checklist:
- [ ] All tests passing locally
- [ ] Linting and type checks pass
- [ ] Database migrations tested with rollback plan
- [ ] Environment variables documented
- [ ] Backup strategy confirmed
- [ ] Monitoring alerts configured
- [ ] Rollback procedure documented
- [ ] Stakeholders notified of maintenance window
+27
View File
@@ -0,0 +1,27 @@
# PicPeak Community Guidelines
## Our Commitment
We are committed to providing a welcoming and inspiring community for all photographers and developers.
## Expected Behavior
* Be respectful and considerate
* Welcome newcomers and help them get started
* Focus on what is best for the community
* Show empathy towards other community members
## Unacceptable Behavior
* Trolling or insulting comments
* Personal attacks
* Public or private harassment
* Publishing others' private information
## Enforcement
Instances of unacceptable behavior may be reported to the project team at conduct@example.com. All complaints will be reviewed and investigated promptly and fairly.
## Attribution
This Code of Conduct is adapted from contributor-covenant.org, version 2.0.
+160
View File
@@ -0,0 +1,160 @@
# Contributing to PicPeak
First off, thank you for considering contributing to PicPeak! It's people like you that make PicPeak such a great tool for photographers worldwide.
## 🤝 Code of Conduct
This project and everyone participating in it is governed by the [PicPeak Code of Conduct](CODE_OF_CONDUCT.md). By participating, you are expected to uphold this code.
## 🎯 How Can I Contribute?
### Reporting Bugs
Before creating bug reports, please check the existing issues as you might find out that you don't need to create one. When you are creating a bug report, please include as many details as possible:
* **Use a clear and descriptive title**
* **Describe the exact steps to reproduce the problem**
* **Provide specific examples to demonstrate the steps**
* **Describe the behavior you observed and what you expected**
* **Include screenshots if possible**
* **Include your environment details** (OS, browser, Docker version, etc.)
### Suggesting Enhancements
Enhancement suggestions are tracked as GitHub issues. When creating an enhancement suggestion, please include:
* **Use a clear and descriptive title**
* **Provide a detailed description of the suggested enhancement**
* **Provide specific examples to demonstrate the enhancement**
* **Describe the current behavior and expected behavior**
* **Explain why this enhancement would be useful**
### Your First Code Contribution
Unsure where to begin? You can start by looking through these issues:
* [Good first issues](https://github.com/the-luap/picpeak/labels/good%20first%20issue) - issues which should only require a few lines of code
* [Help wanted issues](https://github.com/the-luap/picpeak/labels/help%20wanted) - issues which need extra attention
### Pull Requests
1. **Fork the repo** and create your branch from `main`
2. **Install dependencies**:
```bash
cd backend && npm install
cd ../frontend && npm install
```
3. **Make your changes** and ensure:
- Code follows the existing style
- Tests pass: `npm test`
- Linting passes: `npm run lint`
4. **Write tests** if you've added code
5. **Update documentation** if needed
6. **Create a Pull Request**
## 💻 Development Setup
### Prerequisites
- Node.js 18+
- Docker & Docker Compose
- Git
### Local Development
```bash
# Clone your fork
git clone https://github.com/your-username/picpeak.git
cd picpeak
# Install dependencies
cd backend && npm install
cd ../frontend && npm install
# Set up environment
cp .env.example .env
# Edit .env with your settings
# Start development servers
docker-compose -f docker-compose.dev.yml up
```
### Running Tests
```bash
# Backend tests
cd backend && npm test
# Frontend tests
cd frontend && npm test
# E2E tests
npm run test:e2e
```
## 📝 Styleguides
### Git Commit Messages
* Use the present tense ("Add feature" not "Added feature")
* Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
* Limit the first line to 72 characters or less
* Reference issues and pull requests liberally after the first line
* Consider starting the commit message with an applicable emoji:
* 🎨 `:art:` when improving the format/structure of the code
* 🐛 `:bug:` when fixing a bug
* 🔥 `:fire:` when removing code or files
* 📝 `:memo:` when writing docs
* 🚀 `:rocket:` when improving performance
* ✨ `:sparkles:` when adding a new feature
### JavaScript/TypeScript Styleguide
* Use ES6+ features
* Prefer async/await over promises
* Use meaningful variable names
* Add JSDoc comments for functions
* Follow ESLint rules
### React Styleguide
* Use functional components with hooks
* Keep components small and focused
* Use TypeScript for type safety
* Follow the existing folder structure
* Write tests for new components
## 📦 Project Structure
```
picpeak/
├── backend/
│ ├── src/
│ │ ├── routes/ # API endpoints
│ │ ├── services/ # Business logic
│ │ ├── middleware/ # Express middleware
│ │ └── utils/ # Utilities
│ └── migrations/ # Database migrations
├── frontend/
│ ├── src/
│ │ ├── components/ # Reusable components
│ │ ├── pages/ # Page components
│ │ ├── services/ # API services
│ │ └── hooks/ # Custom hooks
│ └── public/ # Static assets
```
## 🔄 Release Process
1. Update version numbers in package.json files
2. Update CHANGELOG.md
3. Create a new release on GitHub
4. Docker images are automatically built and published
## 📮 Contact
- Create an issue for bugs or features
- Join discussions for questions
- Email: picpeak@example.com for security issues
Thank you for contributing! 🎉
-92
View File
@@ -1,92 +0,0 @@
# Deployment Guide - Traefik Production Setup
## Overview
This guide explains how to deploy PicPeak with an external Traefik reverse proxy for production use.
## Fixed Issues
1. **Database Migration**: Added missing `created_at` column to `email_queue` table
2. **502 Bad Gateway**: Properly configured Traefik routing and backend accessibility
3. **Health Checks**: Fixed health check endpoint imports and paths
## Deployment Steps
### 1. Update Environment Variables
Ensure your `.env` file has the correct URLs:
```bash
ADMIN_URL=https://picpeak.nothaft.cloud
FRONTEND_URL=https://picpeak.nothaft.cloud
```
### 2. Build Images
```bash
# Build backend image
docker build -t picpeak-backend:latest ./backend
# Build frontend image
docker build -t picpeak-frontend:latest ./frontend \
--build-arg VITE_API_URL=/api \
--build-arg VITE_UMAMI_URL=${VITE_UMAMI_URL} \
--build-arg VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID}
```
### 3. Deploy with Traefik
Use the new Traefik-specific compose file:
```bash
docker-compose -f docker-compose.traefik.yml up -d
```
### 4. Verify Deployment
Check that all services are healthy:
```bash
# Check container status
docker-compose -f docker-compose.traefik.yml ps
# Check backend health
curl https://picpeak.nothaft.cloud/api/health
# Check logs
docker-compose -f docker-compose.traefik.yml logs -f backend
```
## Key Differences from Standard Deployment
1. **No Internal Nginx**: Traefik handles all routing externally
2. **API Path Stripping**: Traefik strips `/api` prefix when forwarding to backend
3. **Network Configuration**: Services join external `traefik` network
4. **Health Checks**: Backend exposes `/health` endpoint (not `/api/health`)
## Why CI/CD Tests Pass But Production Fails
CI/CD tests typically:
- Use in-memory or temporary databases with fresh migrations
- Don't test through reverse proxy (direct API calls)
- Don't run background services (email processor, etc.)
- Have different network configurations
Production environment has:
- Persistent database that may have migration state issues
- Reverse proxy routing complexity
- All background services running
- Different security and network constraints
## Troubleshooting
### 502 Bad Gateway
- Check Traefik network connectivity: `docker network ls`
- Verify backend is in traefik network: `docker inspect picpeak-backend`
- Check Traefik logs: `docker logs traefik`
### Database Issues
- Connect to database: `docker exec -it picpeak-db psql -U picpeak`
- Check migration status: `SELECT * FROM migrations;`
- Run migrations manually: `docker exec -it picpeak-backend npm run migrate:safe`
### Email Service Errors
- Check email queue: `SELECT * FROM email_queue ORDER BY created_at DESC LIMIT 10;`
- Monitor email processor: `docker logs picpeak-backend | grep "email"`
+162 -288
View File
@@ -1,346 +1,220 @@
# PicPeak Deployment Guide
# 🚀 PicPeak Deployment Guide
This guide covers deploying PicPeak for development and production environments.
This guide will help you deploy PicPeak in production. The entire process takes about 10-15 minutes.
## Table of Contents
- [Quick Start (Development)](#quick-start-development)
- [Production Deployment](#production-deployment)
- [Admin User Setup](#admin-user-setup)
- [Configuration Reference](#configuration-reference)
- [Troubleshooting](#troubleshooting)
## 📋 Prerequisites
## Quick Start (Development)
- A server with Docker and Docker Compose installed
- A domain name (for SSL certificates)
- SMTP credentials for sending emails
- Basic command line knowledge
### 1. Clone and Setup
## 🏃 Quick Deploy (Recommended)
### 1. Clone and Configure
```bash
git clone https://github.com/yourusername/picpeak.git
# Clone the repository
git clone https://github.com/the-luap/picpeak.git
cd picpeak
# Copy environment template
cp .env.example .env
# Start development environment
docker-compose -f docker-compose.dev.yml up -d
```
### 2. Access Services
- Frontend: http://localhost:3005
- Backend API: http://localhost:3001
- MailHog (email testing): http://localhost:8025
### 3. Create Admin User
```bash
docker-compose -f docker-compose.dev.yml exec backend node scripts/create-admin.js \
--email admin@localhost \
--username admin \
--password admin123
```
## Production Deployment
### Prerequisites
- Docker and Docker Compose installed
- Domain with DNS configured
- SSL/TLS handled by reverse proxy (Traefik, Nginx, etc.)
### 1. Environment Setup
```bash
# Copy production template
cp .env.production.example .env
# Generate secure secrets
# Generate a secure JWT secret
echo "JWT_SECRET=$(openssl rand -base64 32)" >> .env
echo "DB_PASSWORD=$(openssl rand -base64 24)" >> .env
# Edit configuration
nano .env
```
Edit `.env` with your configuration:
### 2. Required Environment Variables
Edit your `.env` file with these essential settings:
```env
# Your domain
ADMIN_URL=https://yourdomain.com
FRONTEND_URL=https://yourdomain.com
# Application URLs
FRONTEND_URL=https://your-domain.com
BACKEND_URL=https://your-domain.com
# Database (PostgreSQL)
DB_USER=picpeak
DB_NAME=picpeak
# DB_PASSWORD already generated above
# Email
# Email Configuration (Required for notifications)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=true
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
EMAIL_FROM=noreply@yourdomain.com
```
SMTP_FROM=your-email@gmail.com
### 2. Frontend Configuration
# Admin Configuration
ADMIN_EMAIL=admin@your-domain.com
ADMIN_PASSWORD=your-secure-password
```bash
# Configure frontend for production
echo "VITE_API_URL=/api" > frontend/.env.production
# Database (PostgreSQL for production)
DATABASE_CLIENT=pg
DB_HOST=postgres
DB_NAME=picpeak
DB_USER=picpeak
DB_PASSWORD=secure-db-password
```
### 3. Deploy with Docker Compose
```bash
# Build and start services
# Start all services
docker-compose -f docker-compose.prod.yml up -d
# Check status
docker-compose -f docker-compose.prod.yml ps
# Check logs
docker-compose logs -f
# View logs
docker-compose -f docker-compose.prod.yml logs -f
# Access your site at https://your-domain.com
```
### 4. Deploy with Traefik
## 🔧 Configuration Options
If using Traefik, create `docker-compose.override.yml`:
### Storage Settings
```yaml
version: '3.8'
```env
# Storage paths (default: ./storage)
STORAGE_PATH=./storage
ARCHIVE_PATH=./storage/archives
services:
frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)"
- "traefik.http.routers.picpeak.entrypoints=websecure"
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
networks:
- traefik
- picpeak
networks:
traefik:
external: true
# Gallery expiration (days)
DEFAULT_EXPIRATION_DAYS=30
WARNING_DAYS_BEFORE_EXPIRY=7
```
## Admin User Setup
### Security Settings
### Create First Admin
```env
# Session timeout (minutes)
SESSION_TIMEOUT=60
After deployment, create your admin user:
# Rate limiting
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
RATE_LIMIT_MAX_REQUESTS=100
```
### Analytics (Optional)
```env
# Umami Analytics
VITE_UMAMI_URL=https://analytics.your-domain.com
VITE_UMAMI_WEBSITE_ID=your-website-id
```
## 🔒 SSL/TLS Setup
The production Docker Compose includes automatic SSL via Let's Encrypt:
1. **Ensure your domain points to your server**
2. **Update nginx configuration**:
```bash
nano nginx/nginx.conf
# Replace your-domain.com with your actual domain
```
3. **Start services** - Certbot will automatically obtain certificates
## 📁 Directory Structure
After deployment, your directory structure will be:
```
picpeak/
├── backend/ # API server
├── frontend/ # React app
├── storage/ # Photo storage
│ ├── events/ # Active galleries
│ │ ├── active/ # Current photos
│ │ └── archived/ # Expired galleries
│ ├── thumbnails/ # Generated thumbnails
│ └── uploads/ # User uploads
├── data/ # Database files
└── logs/ # Application logs
```
## 🔄 Maintenance
### Backup
```bash
# Production
docker-compose -f docker-compose.prod.yml exec backend node scripts/create-admin.js \
--email admin@yourdomain.com \
--username admin \
--password yourSecurePassword
# Backup database and photos
./scripts/backup.sh
# Auto-generate password
docker-compose -f docker-compose.prod.yml exec backend node scripts/create-admin.js \
--email admin@yourdomain.com
# Backups are stored in ./backups/
```
The script will display:
- ✅ Admin user created successfully!
- Email: admin@yourdomain.com
- Username: admin
- Login URL: https://yourdomain.com/admin/login
- Password: (save this if auto-generated!)
### Managing Admin Users
```bash
# List admin users
docker-compose -f docker-compose.prod.yml exec backend \
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
-c "SELECT id, username, email, is_active, last_login FROM admin_users;"
# Deactivate user
docker-compose -f docker-compose.prod.yml exec backend \
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
-c "UPDATE admin_users SET is_active = false WHERE email = 'user@example.com';"
```
## Configuration Reference
### Database Configuration
PicPeak automatically detects the environment and uses:
- **Development**: SQLite (`./data/photo_sharing.db`)
- **Production**: PostgreSQL (configured via environment variables)
### Environment Variables
#### Required for Production
| Variable | Description | Example |
|----------|-------------|---------|
| `JWT_SECRET` | JWT signing key | `openssl rand -base64 32` |
| `DB_PASSWORD` | PostgreSQL password | `openssl rand -base64 24` |
| `ADMIN_URL` | Admin panel URL | `https://yourdomain.com` |
| `FRONTEND_URL` | Frontend URL | `https://yourdomain.com` |
| `EMAIL_FROM` | Sender email | `noreply@yourdomain.com` |
#### Email Configuration
| Variable | Description | Example |
|----------|-------------|---------|
| `SMTP_HOST` | SMTP server | `smtp.gmail.com` |
| `SMTP_PORT` | SMTP port | `587` |
| `SMTP_SECURE` | Use TLS | `true` |
| `SMTP_USER` | SMTP username | `your-email@gmail.com` |
| `SMTP_PASS` | SMTP password | App-specific password |
### Storage Paths
- Photos: `./storage/events/active/`
- Archives: `./storage/events/archived/`
- Thumbnails: `./storage/thumbnails/`
- Uploads: `./storage/uploads/`
## Backup and Restore
### Backup Database
```bash
# PostgreSQL backup
docker-compose -f docker-compose.prod.yml exec db \
pg_dump -U picpeak picpeak > backup-$(date +%Y%m%d).sql
# Backup storage
tar -czf storage-backup-$(date +%Y%m%d).tar.gz ./storage
```
### Restore Database
```bash
# PostgreSQL restore
docker-compose -f docker-compose.prod.yml exec -T db \
psql -U picpeak picpeak < backup-20240115.sql
# Restore storage
tar -xzf storage-backup-20240115.tar.gz
```
## Monitoring
### Health Checks
```bash
# Backend health
curl https://yourdomain.com/api/health
# Frontend health
curl https://yourdomain.com/health
```
### Logs
```bash
# All services
docker-compose -f docker-compose.prod.yml logs -f
# Specific service
docker-compose -f docker-compose.prod.yml logs -f backend
# Last 100 lines
docker-compose -f docker-compose.prod.yml logs --tail=100 backend
```
## Troubleshooting
### Backend Won't Start
1. Check database connection:
```bash
docker-compose -f docker-compose.prod.yml logs db
```
2. Verify environment variables:
```bash
docker-compose -f docker-compose.prod.yml exec backend env | grep DB_
```
### Can't Login as Admin
1. Verify admin user exists:
```bash
docker-compose -f docker-compose.prod.yml exec backend \
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
-c "SELECT * FROM admin_users;"
```
2. Reset admin password:
```bash
# Create new admin with different email
docker-compose -f docker-compose.prod.yml exec backend \
node scripts/create-admin.js --email newadmin@yourdomain.com
```
### Photos Not Loading
1. Check file permissions:
```bash
ls -la ./storage/events/active/
```
2. Verify nginx proxy configuration:
```bash
docker-compose -f docker-compose.prod.yml exec frontend \
cat /etc/nginx/conf.d/default.conf
```
### Email Not Sending
1. Check email configuration:
```bash
docker-compose -f docker-compose.prod.yml exec backend env | grep SMTP_
```
2. View email queue:
```bash
docker-compose -f docker-compose.prod.yml exec backend \
psql postgresql://picpeak:$DB_PASSWORD@db:5432/picpeak \
-c "SELECT * FROM email_queue WHERE status = 'failed';"
```
## Maintenance
### Update Application
### Update
```bash
# Pull latest changes
git pull
# Rebuild images
docker-compose -f docker-compose.prod.yml build
# Restart services
docker-compose -f docker-compose.prod.yml up -d
# Rebuild and restart
docker-compose -f docker-compose.prod.yml up -d --build
```
### Clean Up
### Logs
```bash
# Remove unused images
docker image prune -a
# View all logs
docker-compose logs
# Clean up logs
docker-compose -f docker-compose.prod.yml logs --tail=0 -f
# Remove old archives
find ./storage/events/archived -name "*.zip" -mtime +90 -delete
# View specific service
docker-compose logs backend
docker-compose logs frontend
```
## Security Checklist
## 🚨 Troubleshooting
- [ ] Generated secure `JWT_SECRET`
- [ ] Generated secure `DB_PASSWORD`
- [ ] HTTPS enabled via reverse proxy
- [ ] Changed default admin credentials
- [ ] Configured real SMTP server
- [ ] Set file permissions: `chmod 600 .env`
- [ ] Firewall configured
- [ ] Regular backups scheduled
- [ ] Monitoring enabled
### Common Issues
**Photos not appearing:**
- Check storage permissions: `chmod -R 755 storage/`
- Verify file watcher is running: `docker-compose logs backend | grep watcher`
**Email not sending:**
- Test SMTP settings: Admin Panel → Settings → Email → Send Test
- Check email queue: Admin Panel → System → Email Queue
**Can't access admin panel:**
- Default login: Use email/password from `.env`
- Reset password: `docker exec picpeak-backend npm run reset-admin`
### Health Check
```bash
# Check service status
docker-compose ps
# Test backend API
curl https://your-domain.com/api/health
# Check disk space
df -h storage/
```
## 🐳 Alternative Deployment Methods
### Using Docker Swarm
For high availability deployments, see [Docker Swarm Setup](deploy/README.md).
### Manual Installation
If you prefer not to use Docker:
1. Install Node.js 18+
2. Install PostgreSQL
3. Clone repository
4. Install dependencies: `npm install` in both `/backend` and `/frontend`
5. Build frontend: `cd frontend && npm run build`
6. Start services with PM2
## 📞 Support
- 📘 [Documentation](https://github.com/the-luap/picpeak)
- 🐛 [Report Issues](https://github.com/the-luap/picpeak/issues)
- 💬 [Discussions](https://github.com/the-luap/picpeak/discussions)
---
**Need help?** Open an issue on GitHub and we'll assist you!
-111
View File
@@ -1,111 +0,0 @@
# Quick Fix for Migration Error
## Immediate Fix
The error "relation photo_categories already exists" occurs because the database already has tables but the migration tracking doesn't know they were applied.
### Option 1: Use Safe Migration Runner (Recommended)
Update your `docker-compose.prod.yml` to use the safe migration command:
```yaml
backend:
environment:
- NODE_ENV=production
# ... other config ...
```
Then update the `wait-for-db.sh` (already done) to use `npm run migrate:safe` in production.
### Option 2: Quick Manual Fix
If you need to fix the running system immediately:
```bash
# 1. Enter the backend container
docker-compose -f docker-compose.prod.yml exec backend sh
# 2. Run the safe migration script
npm run migrate:safe
# 3. If that fails, manually mark migrations as applied:
docker-compose -f docker-compose.prod.yml exec db psql -U picpeak -d picpeak
# In PostgreSQL:
CREATE TABLE IF NOT EXISTS migrations (
id SERIAL PRIMARY KEY,
filename VARCHAR(255) UNIQUE NOT NULL,
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Mark existing migrations as applied
INSERT INTO migrations (filename) VALUES
('init.js'),
('004_add_categories_and_cms.js'),
('006_add_photo_counter_to_categories.js'),
('007_add_read_at_to_activity_logs.js'),
('008_add_language_support_to_email_templates.js'),
('009_update_german_email_templates.js'),
('010_add_missing_email_templates.js'),
('011_add_user_upload_settings.js'),
('012_add_hero_photo_id.js'),
('013_fix_email_links_and_date_format.js'),
('014_add_default_welcome_message.js'),
('014_add_host_name_to_events.js'),
('015_add_login_attempts_table.js'),
('016_add_auth_security_columns.js'),
('017_add_token_revocation_tables.js')
ON CONFLICT (filename) DO NOTHING;
\q
```
### Option 3: Fresh Start (Nuclear Option)
If you don't have important data yet:
```bash
# Stop everything
docker-compose -f docker-compose.prod.yml down
# Remove database volume
docker volume rm wedding-photo-sharing_postgres_data
# Start fresh
docker-compose -f docker-compose.prod.yml up -d
```
## Root Cause
The issue happens when:
1. Database volume persists between deployments
2. Migration tracking table gets out of sync
3. The original migration runner doesn't check for existing tables
## Permanent Solution
The new safe migration runner (`migrate:safe`) handles this by:
1. Checking if tables exist before creating them
2. Catching "already exists" errors gracefully
3. Auto-detecting existing schema and marking migrations as applied
## Next Steps
After fixing the migration issue:
1. Create admin user:
```bash
docker-compose -f docker-compose.prod.yml exec backend node scripts/create-admin.js \
--username admin \
--email admin@yourdomain.com
```
2. Check health:
```bash
curl http://yourdomain.com/api/health
```
3. Monitor logs:
```bash
docker-compose -f docker-compose.prod.yml logs -f backend
```
-100
View File
@@ -1,100 +0,0 @@
# Production Deployment Fixes
This document describes the fixes applied to resolve production deployment issues in Docker.
## Issues Fixed
### 1. Database Connection Error: "getaddrinfo ENOTFOUND postgres"
**Problem**: The backend was trying to connect to hostname "postgres" but the database service is named "db" in docker-compose.
**Solution**:
- Updated `knexfile.js` to use correct default host "db" instead of "postgres"
- Added `depends_on: db` to backend service in docker-compose.prod.yml
### 2. Backend Starting Before Database Ready
**Problem**: Backend service started before PostgreSQL was ready, causing connection failures.
**Solution**:
- Created `wait-for-db.sh` script that waits for PostgreSQL to be ready
- Updated Dockerfile to install postgresql-client and use the wait script
- Script also runs migrations automatically on startup
### 3. Email Processor Initialization Failure
**Problem**: Email processor tried to initialize on module load before database was available.
**Solution**:
- Modified `emailProcessor.js` to export initialization functions
- Updated `server.js` to call initialization after database is ready
- Added proper error handling for email service initialization
### 4. Missing Environment Variables
**Problem**: Critical storage path environment variables were missing.
**Solution**:
- Added STORAGE_PATH, EVENTS_PATH, and ARCHIVE_PATH to docker-compose.prod.yml
- Created `.env.example` documenting all required environment variables
### 5. Enhanced Health Check
**Problem**: Basic health check didn't verify database connectivity.
**Solution**:
- Updated `/api/health` endpoint to check database connection
- Returns proper HTTP 503 status when unhealthy
## Files Modified
1. **backend/knexfile.js** - Fixed production database defaults
2. **backend/wait-for-db.sh** - Created database wait script
3. **backend/Dockerfile** - Added postgresql-client and wait script
4. **docker-compose.prod.yml** - Added dependencies and environment variables
5. **backend/src/services/emailProcessor.js** - Disabled auto-initialization
6. **backend/server.js** - Added email initialization and improved health check
7. **backend/.env.example** - Created environment variable documentation
## Deployment Steps
1. Ensure all environment variables are set according to `.env.example`
2. Build and deploy with docker-compose:
```bash
docker-compose -f docker-compose.prod.yml build
docker-compose -f docker-compose.prod.yml up -d
```
3. The backend will now:
- Wait for PostgreSQL to be ready
- Run migrations automatically
- Initialize all services in proper order
- Provide health status at `/api/health`
## Verification
Check deployment health:
```bash
curl http://localhost/api/health
```
Expected response:
```json
{
"status": "ok",
"database": "connected",
"timestamp": "2025-07-13T20:30:00.000Z"
}
```
## Email Configuration
Email service requires configuration in the database. If email is not configured:
- The service will log a warning but continue running
- Emails will be queued but not sent
- Configure email settings in the admin panel after deployment
## PostgreSQL Connection Fix
### Issue: "no pg_hba.conf entry for host"
This error occurs when PostgreSQL requires SSL but the client connects without encryption.
### Solution:
- Disabled SSL requirement for PostgreSQL in Docker environment (`ssl=off`)
- Added proper authentication method (`scram-sha-256`)
- This is acceptable for internal Docker networks where all traffic is isolated
### Security Note:
For production deployments exposed to the internet:
1. Use SSL certificates for PostgreSQL
2. Or ensure the database is only accessible within the Docker network
3. Never expose PostgreSQL port (5432) directly to the internet
+1 -1
View File
@@ -51,7 +51,7 @@ openssl rand -hex 32
```bash
# Clone repository
git clone https://github.com/yourusername/wedding-photo-sharing.git
git clone https://github.com/the-luap/wedding-photo-sharing.git
cd wedding-photo-sharing
# Create required directories
-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! 🎨
+175 -22
View File
@@ -1,32 +1,185 @@
# Photo Sharing Platform
# 📸 PicPeak - Open Source Photo Sharing for Events
A secure, self-hosted photo sharing platform designed for weddings and events. Features automatic expiration, email notifications, and simple file-based management.
<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>
## Features
**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.
- 🔒 Password Protected Galleries
- ⏰ Automatic Expiration
- 📧 Email Notifications
- 📁 Simple File Management
- 📊 Analytics Integration
- 🎨 Customizable Themes
- 📱 Mobile Responsive
- ⚡ Docker Ready
![PicPeak Gallery Preview](docs/screenshot-gallery.png)
## Quick Start
## 🌟 Why Choose PicPeak?
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`
Unlike expensive SaaS solutions, PicPeak gives you:
Default credentials: Check ADMIN_CREDENTIALS.txt after first setup
- **💰 No Monthly Fees** - One-time setup, unlimited galleries
- **🔒 Complete Data Control** - Your photos stay on your server
- **🎨 White-Label Ready** - Full branding customization
- **📱 Mobile-First Design** - Beautiful on all devices
- **🚀 Lightning Fast** - Optimized performance and caching
- **🌍 Multi-Language** - Built-in i18n support (EN, DE)
## Documentation
## ✨ Key Features
See DEPLOYMENT.md for detailed deployment instructions.
### For Photographers
- 📁 **Drag & Drop Upload** - Simply drop photos into folders
-**Auto-Expiring Galleries** - Set expiration dates (default: 30 days)
- 🔐 **Password Protection** - Secure client galleries
- 📧 **Automated Emails** - Creation confirmations and expiration warnings
- 📊 **Analytics Dashboard** - Track views, downloads, and engagement
- 🎨 **Custom Themes** - Match your brand perfectly
## License
### For Clients
- 🖼️ **Beautiful Galleries** - Clean, modern interface
- 📱 **Mobile Optimized** - Swipe through photos on any device
- ⬇️ **Bulk Downloads** - Download all photos with one click
- 🔍 **Smart Search** - Find photos quickly
- 📤 **Guest Uploads** - Optional client photo uploads
MIT License
### Technical Excellence
- 🐳 **Docker Ready** - Deploy in minutes
- 🔄 **Auto-Processing** - Automatic thumbnail generation
- 💾 **Smart Storage** - Automatic archiving of expired galleries
- 🛡️ **Security First** - JWT auth, rate limiting, CORS protection
- 📈 **Scalable** - From small studios to large agencies
## 🚀 Quick Start
Get PicPeak running in under 5 minutes:
```bash
# Clone the repository
git clone https://github.com/the-luap/picpeak.git
cd picpeak
# Copy environment template
cp .env.example .env
# Edit configuration (required: JWT_SECRET)
nano .env
# Start with Docker Compose
docker-compose up -d
# Access at http://localhost:3005
```
## 📖 Documentation
- 📘 [**Deployment Guide**](DEPLOYMENT.md) - Detailed installation instructions
- 🤝 [**Contributing**](CONTRIBUTING.md) - How to contribute
- 📜 [**License**](LICENSE) - MIT License
- 🔒 [**Security**](SECURITY.md) - Security policies
- 📋 [**Code of Conduct**](CODE_OF_CONDUCT.md) - Community guidelines
## 🎯 Use Cases
Perfect for:
- 💒 **Wedding Photographers** - Share ceremony photos securely
- 🎂 **Event Photography** - Birthday parties, corporate events
- 📸 **Portrait Studios** - Client galleries with download limits
- 🏢 **Corporate Events** - Internal photo sharing with branding
- 🎓 **School Photography** - Secure parent access with expiration
## 🏗️ Tech Stack
- **Backend**: Node.js, Express, SQLite/PostgreSQL
- **Frontend**: React, Tailwind CSS, Framer Motion
- **Storage**: File-based with automatic archiving
- **Email**: SMTP with customizable templates
- **Analytics**: Privacy-focused with Umami integration
## 🤝 Contributing
We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome.
See our [Contributing Guide](CONTRIBUTING.md) for details.
## 📊 Comparison with Alternatives
| Feature | PicPeak | PicDrop | Scrapbook.de |
|---------|---------|---------|--------------|
| Self-Hosted | ✅ | ❌ | ❌ |
| Custom Branding | ✅ Full | Limited | Limited |
| Monthly Cost | $0 | $29-199 | €19-99 |
| Storage Limit | Unlimited* | 50-500GB | 100-1000GB |
| Client Uploads | ✅ | ✅ | ✅ |
| API Access | ✅ | Paid | ❌ |
| Open Source | ✅ | ❌ | ❌ |
*Limited only by your server storage
## 🛡️ Security
PicPeak takes security seriously:
- 🔐 Password hashing with bcrypt
- 🎫 JWT-based authentication
- 🚦 Rate limiting on all endpoints
- 🛡️ CORS protection
- 📝 Activity logging
- 🔒 Secure file access
Found a security issue? Please email security@example.com
## 📸 Screenshots
### 🎛️ **Admin Dashboard**
Get a complete overview of your photo galleries, analytics, and system status.
<img src="docs/screenshot-dashboard.png" alt="PicPeak Admin Dashboard" width="800" />
### 📊 **Analytics & Insights**
Track gallery performance, view statistics, and monitor user engagement.
<img src="docs/screenshot-analytics.png" alt="PicPeak Analytics Dashboard" width="800" />
### 📁 **Event Management**
Organize and manage your photo galleries with intuitive event management tools.
<img src="docs/screenshots-events.png" alt="PicPeak Events Management" width="800" />
### ✨ **Key Interface Highlights**
<details>
<summary>👆 Click to see more interface details</summary>
#### What makes PicPeak's interface special:
- **🎨 Clean Design**: Modern, photographer-friendly interface
- **📱 Responsive**: Perfect on desktop, tablet, and mobile
- **⚡ Fast Loading**: Optimized for quick photo browsing
- **🔒 Secure Access**: Password-protected galleries with expiration
- **📤 Easy Uploads**: Drag & drop functionality for effortless photo management
- **🎯 Client-Focused**: Intuitive gallery experience for your clients
</details>
## 🙏 Acknowledgments
PicPeak is inspired by the best features of commercial platforms while remaining completely open source. Special thanks to all contributors who make this project possible.
## 📄 License
PicPeak is released under the [MIT License](LICENSE). Use it freely for personal or commercial projects.
## 🚀 Ready to Get Started?
1.**Star this repository** to show your support
2. 📖 Read the [Deployment Guide](DEPLOYMENT.md)
3. 🐛 Report issues or request features
4. 🤝 Join our community and contribute!
---
<p align="center">
Made with ❤️ by photographers, for photographers
<br>
<a href="https://github.com/the-luap/picpeak">GitHub</a> •
<a href="DEPLOYMENT.md">Documentation</a> •
<a href="https://github.com/the-luap/picpeak/issues">Support</a>
</p>
+85
View File
@@ -0,0 +1,85 @@
# Security Policy
## Supported Versions
We release patches for security vulnerabilities. Currently supported versions:
| Version | Supported |
| ------- | ------------------ |
| 1.x.x | :white_check_mark: |
| < 1.0 | :x: |
## Reporting a Vulnerability
We take the security of PicPeak seriously. If you have discovered a security vulnerability, please follow these steps:
### 1. **Do NOT create a public GitHub issue**
### 2. Email us at security@example.com with:
- Description of the vulnerability
- Steps to reproduce
- Potential impact
- Suggested fix (if any)
### 3. You can expect:
- Acknowledgment within 48 hours
- Regular updates on our progress
- Credit in the fix announcement (unless you prefer to remain anonymous)
## Security Measures
PicPeak implements several security measures:
### Authentication & Authorization
- JWT-based authentication with secure token storage
- bcrypt password hashing with configurable rounds
- Role-based access control for admin functions
- Session timeout management
### Input Validation
- All user inputs are validated and sanitized
- SQL injection prevention through parameterized queries
- XSS protection via Content Security Policy
- File upload restrictions and validation
### Rate Limiting
- API rate limiting to prevent abuse
- Brute force protection on authentication endpoints
- Configurable limits per endpoint
### Data Protection
- HTTPS enforcement in production
- Secure cookie settings
- CORS configuration
- Sensitive data encryption
### Infrastructure
- Regular dependency updates
- Security headers (HSTS, X-Frame-Options, etc.)
- Activity logging for audit trails
- Automated backups
## Best Practices for Deployment
1. **Always use HTTPS** in production
2. **Change default passwords** immediately
3. **Keep dependencies updated** regularly
4. **Configure firewall rules** appropriately
5. **Monitor logs** for suspicious activity
6. **Backup regularly** and test restoration
## Vulnerability Disclosure
We believe in responsible disclosure. Once a vulnerability is fixed:
1. We'll publish a security advisory
2. Credit researchers (with permission)
3. Detail the impact and mitigation steps
4. Release patches for all supported versions
## Contact
- Security issues: security@example.com
- General support: https://github.com/the-luap/picpeak/issues
Thank you for helping keep PicPeak and its users safe!
-252
View File
@@ -1,252 +0,0 @@
# PicPeak - Complete Setup Guide
## Repository Created Successfully! 🎉
Your PicPeak repository has been created at:
**https://gitea.nothaft.cloud/paul/picpeak**
## What's Been Created
I've uploaded the core files needed to run the application:
### ✅ Created Files:
- `.gitignore` - Git ignore rules
- `.dockerignore` - Docker ignore rules
- `.env.example` - Environment configuration template
- `docker-compose.yml` - Development Docker setup
- `docker-compose.prod.yml` - Production Docker setup
- `backend/` - Core backend files including:
- `package.json` - Dependencies
- `server.js` - Main server file
- `Dockerfile` - Backend container config
- Core routes and services
- `setup-remaining-files.sh` - Script to create remaining files
## Next Steps to Complete Setup
### 1. Clone the Repository
```bash
git clone https://gitea.local.nothaft.cloud/paul/picpeak.git
cd picpeak
```
### 2. Run the Setup Script
```bash
chmod +x setup-remaining-files.sh
./setup-remaining-files.sh
```
This will create all remaining directories and files needed.
### 3. Create Critical Service Files
Due to the large number of files, I've created the most important ones. You'll need to add these remaining backend services:
#### backend/src/services/expirationChecker.js
```javascript
const cron = require('node-cron');
const { db } = require('../database/db');
const { archiveEvent } = require('./archiveService');
const logger = require('../utils/logger');
function startExpirationChecker() {
// Check every hour for expired events
cron.schedule('0 * * * *', async () => {
await checkExpirations();
});
logger.info('Expiration checker started');
}
async function checkExpirations() {
try {
const now = new Date();
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
// Check for events needing warning emails
const eventsNeedingWarning = await db('events')
.where('is_active', true)
.where('is_archived', false)
.where('expires_at', '<=', warningDate)
.where('expires_at', '>', now);
for (const event of eventsNeedingWarning) {
const existingWarning = await db('email_queue')
.where('event_id', event.id)
.where('email_type', 'warning')
.first();
if (!existingWarning) {
await queueExpirationWarning(event);
}
}
// Check for expired events
const expiredEvents = await db('events')
.where('is_active', true)
.where('is_archived', false)
.where('expires_at', '<=', now);
for (const event of expiredEvents) {
await handleExpiredEvent(event);
}
} catch (error) {
logger.error('Error checking expirations:', error);
}
}
async function queueExpirationWarning(event) {
const daysRemaining = Math.ceil((new Date(event.expires_at) - new Date()) / (1000 * 60 * 60 * 24));
await db('email_queue').insert({
event_id: event.id,
recipient_email: event.host_email,
email_type: 'warning',
email_data: JSON.stringify({
event_name: event.event_name,
days_remaining: daysRemaining,
share_link: event.share_link
})
});
logger.info(`Queued expiration warning for event ${event.slug}`);
}
async function handleExpiredEvent(event) {
try {
await db('events').where('id', event.id).update({ is_active: false });
await db('email_queue').insert([
{
event_id: event.id,
recipient_email: event.host_email,
email_type: 'expiration',
email_data: JSON.stringify({
event_name: event.event_name
})
},
{
event_id: event.id,
recipient_email: event.admin_email,
email_type: 'expiration',
email_data: JSON.stringify({
event_name: event.event_name,
event_slug: event.slug
})
}
]);
await archiveEvent(event);
logger.info(`Handled expiration for event ${event.slug}`);
} catch (error) {
logger.error(`Error handling expired event ${event.slug}:`, error);
}
}
module.exports = { startExpirationChecker };
```
### 4. Create Frontend Files
The frontend needs these key files in `frontend/src/`:
#### App.js
```javascript
import React from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from './contexts/AuthContext';
import ProtectedRoute from './components/ProtectedRoute';
// Pages
import Login from './pages/Login';
import Gallery from './pages/Gallery';
import AdminLogin from './pages/admin/Login';
import AdminDashboard from './pages/admin/Dashboard';
function App() {
return (
<AuthProvider>
<Routes>
<Route path="/" element={<Navigate to="/gallery" />} />
<Route path="/gallery/:slug/:token?" element={<Gallery />} />
<Route path="/login/:slug" element={<Login />} />
<Route path="/admin/login" element={<AdminLogin />} />
<Route path="/admin" element={
<ProtectedRoute>
<AdminDashboard />
</ProtectedRoute>
} />
</Routes>
</AuthProvider>
);
}
export default App;
```
### 5. Install Dependencies
```bash
# Backend
cd backend
npm install
# Frontend
cd ../frontend
npm install
```
### 6. Configure Environment
Copy `.env.example` to `.env` and update with your settings:
```bash
cp .env.example .env
nano .env
```
### 7. Start Development Environment
```bash
# From root directory
docker-compose up
```
- Backend: http://localhost:3000
- Frontend: http://localhost:3001
- MailHog: http://localhost:8025
## Key Features Implemented
- ✅ Password-protected galleries
- ✅ Automatic expiration with email warnings
- ✅ File-based photo management
- ✅ ZIP archiving on expiration
- ✅ Separate admin and public interfaces
- ✅ Email notifications at all stages
- ✅ Mobile-responsive design
- ✅ Docker deployment ready
## Production Deployment
1. Update `.env` with production values
2. Run `./scripts/install.sh` on your server
3. Configure SSL with `./scripts/setup-ssl.sh`
4. Start with `docker-compose -f docker-compose.prod.yml up -d`
## Need Help?
The complete implementation includes:
- Backend API with all routes
- React frontend with admin panel
- Email service with templates
- Automatic file watching
- Expiration checking
- Archive service
- Docker configuration
- Deployment scripts
All core functionality from your PRD has been implemented. You may need to create some additional UI components based on your specific design preferences.
Default admin credentials: **admin / admin123** (change immediately!)
-47
View File
@@ -1,47 +0,0 @@
# TODO - Open Items Before Release
## Priority Items
- [ ] **Gallery Mobile View**
- Logout button should only show logo icon (no text)
- If photo upload is enabled, move upload button inside menu (not on top bar)
- Top bar should show: logo (left), gallery title (center), event date + expiration date
- [ ] **Gallery Preview**
- Preview should correctly reflect the selected grid layout style
- Add grid style selector above current top bar
- Selector should match the style of event template settings grid selector
- [ ] **Hero Grid Layout**
- Top bar: only menu and logout buttons
- Title + logo displayed centered on hero photo
- Event date and expiration date also on hero photo
- No logo/title in top bar
- [ ] **Logo Testing** - Test new PicPeak logos across all grid styles
- [ ] **Welcome Message**
- Add welcome message to email template when creating new event
- Use as personal message in the email
- [ ] **Gallery Upload Function**
- Fix scrolling in upload popup when multiple images selected
- Save/Cancel buttons unreachable due to incorrect scroll formatting
- [ ] **Watermarks** - Test watermark functionality, styling, and image application
- [ ] **Dashboard Activities** - Remove "show all" link from latest activities widget
- [ ] **Security Audit** - Perform security review and code audit
- [ ] **Drone CI/CD** - Update drone.yaml configuration
- [ ] **Version Management** - Implement automatic version updates on commits/builds
## Completed Items
_(Move completed items here with date)_
---
Last updated: 2025-07-10
-280
View File
@@ -1,280 +0,0 @@
# Traefik Deployment Guide
This guide explains how to deploy the PicPeak application with Traefik as the reverse proxy.
## Overview
The application consists of:
- **Frontend**: React app served by nginx (port 80)
- **Backend**: Node.js API (port 3000)
- **Database**: PostgreSQL (port 5432, internal only)
## Traefik Configuration
### 1. Docker Labels for Traefik
Add these labels to your `docker-compose.prod.yml` services:
```yaml
services:
frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak-frontend.rule=Host(`picpeak.yourdomain.com`)"
- "traefik.http.routers.picpeak-frontend.entrypoints=websecure"
- "traefik.http.routers.picpeak-frontend.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-frontend.loadbalancer.server.port=80"
# Priority for catch-all route
- "traefik.http.routers.picpeak-frontend.priority=1"
backend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak-api.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/api`)"
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3000"
# Higher priority for API routes
- "traefik.http.routers.picpeak-api.priority=10"
# Additional routes for backend static files
- "traefik.http.routers.picpeak-uploads.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/uploads`)"
- "traefik.http.routers.picpeak-uploads.entrypoints=websecure"
- "traefik.http.routers.picpeak-uploads.tls.certresolver=letsencrypt"
- "traefik.http.routers.picpeak-uploads.service=picpeak-api"
- "traefik.http.routers.picpeak-uploads.priority=10"
- "traefik.http.routers.picpeak-images.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/images`)"
- "traefik.http.routers.picpeak-images.entrypoints=websecure"
- "traefik.http.routers.picpeak-images.tls.certresolver=letsencrypt"
- "traefik.http.routers.picpeak-images.service=picpeak-api"
- "traefik.http.routers.picpeak-images.priority=10"
```
### 2. Network Configuration
Ensure your services are on the Traefik network:
```yaml
networks:
picpeak:
external: false
traefik:
external: true
services:
frontend:
networks:
- picpeak
- traefik
backend:
networks:
- picpeak
- traefik
db:
networks:
- picpeak # Don't expose to traefik
```
### 3. Remove Nginx Service
Since you're using Traefik, remove the nginx service from `docker-compose.prod.yml`:
```yaml
# Remove this entire service:
# nginx:
# image: nginx:alpine
# ...
```
## Frontend Configuration
The frontend is built with the API URL set to `/api`. This is important because:
1. All API calls will be relative to the same domain
2. Traefik will route `/api/*` to the backend service
3. No CORS issues since everything is on the same domain
## Environment Variables
Ensure these are set correctly:
```bash
# Backend needs to know the public URLs
ADMIN_URL=https://picpeak.yourdomain.com
FRONTEND_URL=https://picpeak.yourdomain.com
# Backend API is accessed via /api path
API_URL=https://picpeak.yourdomain.com/api
```
## Complete Example
Here's a complete `docker-compose.prod.yml` for Traefik:
```yaml
version: '3.8'
networks:
picpeak:
external: false
traefik:
external: true
services:
backend:
image: picpeak-backend:latest
build:
context: ./backend
dockerfile: Dockerfile
restart: unless-stopped
depends_on:
- db
environment:
- NODE_ENV=production
- PORT=3000
- JWT_SECRET=${JWT_SECRET}
- ADMIN_URL=https://picpeak.yourdomain.com
- FRONTEND_URL=https://picpeak.yourdomain.com
- DATABASE_CLIENT=pg
- DB_HOST=db
- DB_PORT=5432
- DB_USER=${DB_USER:-picpeak}
- DB_PASSWORD=${DB_PASSWORD}
- DB_NAME=${DB_NAME:-picpeak}
- SMTP_HOST=${SMTP_HOST}
- SMTP_PORT=${SMTP_PORT}
- SMTP_SECURE=${SMTP_SECURE}
- SMTP_USER=${SMTP_USER}
- SMTP_PASS=${SMTP_PASS}
- EMAIL_FROM=${EMAIL_FROM}
- STORAGE_PATH=/app/storage
- EVENTS_PATH=/app/storage/events
- ARCHIVE_PATH=/app/storage/events/archived
volumes:
- ./storage:/app/storage
- ./data:/app/data
- ./logs:/app/logs
networks:
- picpeak
- traefik
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak-api.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/api`)"
- "traefik.http.routers.picpeak-api.entrypoints=websecure"
- "traefik.http.routers.picpeak-api.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-api.loadbalancer.server.port=3000"
- "traefik.http.routers.picpeak-api.priority=10"
- "traefik.http.routers.picpeak-uploads.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/uploads`)"
- "traefik.http.routers.picpeak-uploads.entrypoints=websecure"
- "traefik.http.routers.picpeak-uploads.tls.certresolver=letsencrypt"
- "traefik.http.routers.picpeak-uploads.service=picpeak-api"
- "traefik.http.routers.picpeak-uploads.priority=10"
- "traefik.http.routers.picpeak-images.rule=Host(`picpeak.yourdomain.com`) && PathPrefix(`/images`)"
- "traefik.http.routers.picpeak-images.entrypoints=websecure"
- "traefik.http.routers.picpeak-images.tls.certresolver=letsencrypt"
- "traefik.http.routers.picpeak-images.service=picpeak-api"
- "traefik.http.routers.picpeak-images.priority=10"
frontend:
image: picpeak-frontend:latest
build:
context: ./frontend
dockerfile: Dockerfile
args:
- VITE_API_URL=/api
restart: unless-stopped
depends_on:
- backend
networks:
- picpeak
- traefik
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak-frontend.rule=Host(`picpeak.yourdomain.com`)"
- "traefik.http.routers.picpeak-frontend.entrypoints=websecure"
- "traefik.http.routers.picpeak-frontend.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak-frontend.loadbalancer.server.port=80"
- "traefik.http.routers.picpeak-frontend.priority=1"
db:
image: postgres:14-alpine
restart: unless-stopped
environment:
- POSTGRES_USER=${DB_USER:-picpeak}
- POSTGRES_PASSWORD=${DB_PASSWORD}
- POSTGRES_DB=${DB_NAME:-picpeak}
- POSTGRES_HOST_AUTH_METHOD=scram-sha-256
- POSTGRES_INITDB_ARGS=--auth-host=scram-sha-256 --auth-local=trust
volumes:
- postgres_data:/var/lib/postgresql/data
networks:
- picpeak
command: postgres -c ssl=off
volumes:
postgres_data:
```
## Troubleshooting
### 502 Bad Gateway Errors
1. **Check if backend is running**:
```bash
docker-compose -f docker-compose.prod.yml ps
docker-compose -f docker-compose.prod.yml logs backend
```
2. **Verify Traefik can reach the backend**:
- Ensure both services are on the same Docker network
- Check Traefik logs: `docker logs traefik`
3. **Check backend health**:
```bash
docker-compose -f docker-compose.prod.yml exec backend curl http://localhost:3000/api/health
```
### Frontend Can't Reach API
1. **Verify API paths don't have double `/api`**:
- Frontend should call `/auth/admin/login`, not `/api/auth/admin/login`
- The base URL in axios should be `/api`
2. **Check browser console for actual URLs being called**
3. **Ensure Traefik routing rules are correct**:
- API routes should have higher priority than frontend catch-all
### CORS Issues
Should not occur since everything is on the same domain. If you see CORS errors:
1. Check that `FRONTEND_URL` and `ADMIN_URL` match your actual domain
2. Ensure you're not mixing HTTP and HTTPS
## Testing the Setup
1. **Test API directly**:
```bash
curl https://picpeak.yourdomain.com/api/health
```
2. **Test frontend**:
```bash
curl https://picpeak.yourdomain.com/
```
3. **Test admin login**:
- Navigate to https://picpeak.yourdomain.com/admin/login
- Check browser console for any errors
## Important Notes
1. **SSL/TLS**: Traefik handles SSL termination, so the backend doesn't need SSL
2. **Port Exposure**: Don't expose backend ports directly - let Traefik handle routing
3. **Health Checks**: Configure Traefik health checks for better reliability
4. **Rate Limiting**: Consider adding Traefik rate limiting middleware for API routes
-134
View File
@@ -1,134 +0,0 @@
# Traefik Troubleshooting Guide
## Common Issues and Solutions
### 1. 404 Errors on API Routes
**Problem**: Getting 404 errors when accessing `/api/*` routes
**Causes**:
- Traefik routing rules not properly configured
- Backend container not healthy
- Path stripping not working correctly
**Solutions**:
1. **Check container health**:
```bash
docker ps # Check if backend is running
docker logs picpeak-backend # Check for startup errors
```
2. **Test backend directly**:
```bash
# Access backend container
docker exec -it picpeak-backend sh
# Test health endpoint
wget -O- http://localhost:3000/health
# Test public settings endpoint
wget -O- http://localhost:3000/public/settings
```
3. **Check Traefik routing**:
```bash
# Check if routes are registered in Traefik
curl https://traefik.yourdomain.com/api/http/routers | jq '.[] | select(.rule | contains("picpeak"))'
```
### 2. Backend Not Accessible Through Traefik
**Key Configuration Points**:
1. **Traefik Labels** (in deploy section):
- `traefik.enable=true` - Enable Traefik for this container
- `traefik.docker.network=proxy` - Specify which network Traefik should use
- `traefik.http.routers.picpeak-backend.priority=100` - Higher priority for API routes
2. **Path Stripping**:
- Frontend expects `/api/*` but backend serves routes without `/api` prefix
- Middleware strips `/api` before forwarding to backend
3. **Network Configuration**:
- Backend must be in both `picpeak` (internal) and `proxy` (Traefik) networks
### 3. Environment Variable Issues
**Critical Variables**:
- `ADMIN_URL` and `FRONTEND_URL` must match your actual domain
- These affect CORS configuration
**Example .env**:
```env
# URLs
ADMIN_URL=https://picpeak.local.nothaft.cloud
FRONTEND_URL=https://picpeak.local.nothaft.cloud
# Database
DB_USER=picpeak
DB_PASSWORD=your_secure_password
DB_NAME=picpeak
# JWT
JWT_SECRET=your_secure_jwt_secret
# Email (optional)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_SECURE=true
SMTP_USER=noreply@example.com
SMTP_PASS=smtp_password
EMAIL_FROM=noreply@example.com
```
### 4. Debugging Steps
1. **Check if backend is receiving requests**:
```bash
# Watch backend logs
docker logs -f picpeak-backend
# Look for incoming requests when you try to access the admin page
```
2. **Test API routes directly**:
```bash
# From outside
curl -v https://picpeak.local.nothaft.cloud/api/public/settings
# Should see backend logs if request reaches container
```
3. **Verify Traefik middleware**:
```bash
# Check if stripprefix middleware exists
curl https://traefik.yourdomain.com/api/http/middlewares | jq '.[] | select(.name | contains("picpeak"))'
```
### 5. Quick Fix Checklist
- [ ] Backend container is healthy (`docker ps`)
- [ ] Backend is in both networks (`docker inspect picpeak-backend | grep -A 20 Networks`)
- [ ] Traefik labels use correct network (`traefik.docker.network=proxy`)
- [ ] Priority is set correctly (backend: 100, frontend: 10)
- [ ] ADMIN_URL and FRONTEND_URL match your domain
- [ ] Database is accessible from backend
- [ ] Migrations have run successfully
### 6. Alternative Testing
If Traefik routing is problematic, test backend directly:
```bash
# Port forward to test backend directly
docker run --rm -it --network picpeak alpine/curl curl http://backend:3000/health
# Or expose backend port temporarily
docker run -d --name picpeak-backend-test \
--network picpeak \
-p 3001:3000 \
registry.local.nothaft.cloud/picpeak-backend:latest
```
Then access http://localhost:3001/health to verify backend is working.
+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.
+161
View File
@@ -0,0 +1,161 @@
# Security Logging Documentation
## Overview
This document describes the comprehensive security logging implemented in the PicPeak application to track authentication failures, rate limiting, and suspicious activities.
## Log Files
### 1. **security.log**
- Location: `logs/security.log`
- Contains: All security-related events (authentication, rate limiting, suspicious activity)
- Max Size: 20MB with rotation (keeps 10 files)
- Format: JSON with timestamp
### 2. **error.log**
- Location: `logs/error.log`
- Contains: All error-level logs including auth failures
- Max Size: 10MB with rotation (keeps 5 files)
### 3. **combined.log**
- Location: `logs/combined.log`
- Contains: All logs (info, warn, error)
- Max Size: 50MB with rotation (keeps 10 files)
## Security Events Logged
### Rate Limiting
When rate limits are exceeded, the following is logged:
```json
{
"timestamp": "2024-01-18 14:23:45.123",
"level": "warn",
"message": "Rate limit exceeded",
"security": true,
"ip": "192.168.1.1",
"path": "/api/admin/login",
"method": "POST",
"authenticated": false,
"userAgent": "Mozilla/5.0...",
"referer": "https://app.example.com",
"origin": "https://app.example.com",
"headers": {
"x-forwarded-for": "192.168.1.1",
"x-real-ip": "192.168.1.1"
},
"requestUrl": "/api/admin/login",
"rateLimitInfo": {
"limit": 5,
"current": 6,
"remaining": 0,
"resetTime": "2024-01-18T14:38:45.123Z"
}
}
```
### Authentication Failures
#### Admin Login Failures
- Tracked in `login_attempts` table
- Logged with: IP address, username, user agent, timestamp
- Account lockout after 5 failures in 15 minutes
#### Gallery Password Failures
- Tracked in `access_logs` table with action='login_fail'
- Logged with: event_id, IP address, user agent
- Gallery lockout after 5 failures in 15 minutes
### JWT Validation Failures
```json
{
"timestamp": "2024-01-18 14:23:45.123",
"level": "warn",
"message": "JWT validation failed",
"ip": "192.168.1.1",
"path": "/api/admin/events",
"method": "GET",
"userAgent": "Mozilla/5.0...",
"error": "TokenExpiredError",
"message": "jwt expired"
}
```
### Suspicious Activity
- Multiple IPs attempting login for same account
- Token usage from different IP than issued
- Token usage after password change
- Revoked token usage attempts
## Configuration Settings
All rate limiting settings are configurable via the admin panel:
| Setting | Default | Range | Description |
|---------|---------|-------|-------------|
| rate_limit_enabled | true | - | Enable/disable rate limiting |
| rate_limit_window_minutes | 15 | 1-60 | Time window for rate limit |
| rate_limit_max_requests | 1000 | 10-10000 | Max requests for general endpoints |
| rate_limit_auth_max_requests | 5 | 1-100 | Max requests for auth endpoints |
| rate_limit_skip_authenticated | true | - | Skip rate limit for authenticated requests |
| rate_limit_public_endpoints_only | false | - | Only rate limit public endpoints |
## Database Tables
### login_attempts
```sql
- id
- username
- ip_address
- user_agent
- success (boolean)
- created_at
```
### access_logs
```sql
- id
- event_id
- ip_address
- user_agent
- action ('view', 'download', 'login_success', 'login_fail')
- photo_id (nullable)
- created_at
```
## Environment Variables
- `LOG_LEVEL`: Set logging level (default: 'info')
- `LOG_TO_CONSOLE`: Enable console logging in production (default: false)
## Monitoring Recommendations
1. **Set up alerts for:**
- Rate limit exceeded events (possible DDoS)
- Multiple failed login attempts from same IP
- Account lockout events
- JWT validation failures spike
2. **Regular review:**
- Check security.log for patterns
- Review login_attempts table for brute force attempts
- Monitor access_logs for suspicious gallery access patterns
3. **Log analysis tools:**
- Use log aggregation tools (ELK stack, Splunk)
- Set up dashboards for security metrics
- Configure alerts for threshold breaches
## Production Deployment Notes
1. Ensure logs directory has proper permissions
2. Set up log rotation outside of application if needed
3. Consider shipping logs to centralized logging service
4. Monitor disk space for log files
5. Set `LOG_TO_CONSOLE=true` for container deployments
## Security Best Practices
1. Never log sensitive data (passwords, tokens)
2. Use generic error messages to prevent user enumeration
3. Clean up old login attempts regularly (7 days retention)
4. Monitor for unusual patterns in real-time
5. Keep rate limit settings appropriate for your usage
@@ -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
};
@@ -0,0 +1,234 @@
exports.up = async function(knex) {
// Update gallery_created template with proper German translation
await knex('email_templates')
.where('template_key', 'gallery_created')
.update({
subject_de: 'Ihre Fotogalerie ist bereit!',
body_html_de: `<h2>Galerie erfolgreich erstellt</h2>
<p>Liebe(r) {{host_name}},</p>
<p>Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!</p>
{{#if welcome_message}}
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">Persönliche Nachricht:</p>
<p style="margin: 0; color: #4b5563;">{{welcome_message}}</p>
</div>
{{/if}}
<p><strong>Galerie-Details:</strong></p>
<ul>
<li>Veranstaltungsdatum: {{event_date}}</li>
<li>Galerie-Link: <a href="{{gallery_link}}" style="color: #5C8762;">{{gallery_link}}</a></li>
<li>Passwort: {{gallery_password}}</li>
<li>Ablaufdatum: {{expiry_date}}</li>
</ul>
<p>Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.</p>
<p style="background-color: #FEF3C7; padding: 15px; border-radius: 5px; border-left: 4px solid #F59E0B;">
<strong>Wichtig:</strong> Diese Galerie läuft am {{expiry_date}} ab. Nach diesem Datum werden die Fotos archiviert und sind nicht mehr zugänglich.
</p>
<a href="{{gallery_link}}" style="display: inline-block; padding: 12px 30px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 500; margin: 20px 0;">Galerie anzeigen</a>
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
body_text_de: `Galerie erfolgreich erstellt
Liebe(r) {{host_name}},
Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!
{{#if welcome_message}}
Persönliche Nachricht:
{{welcome_message}}
{{/if}}
Galerie-Details:
- Veranstaltungsdatum: {{event_date}}
- Galerie-Link: {{gallery_link}}
- Passwort: {{gallery_password}}
- Ablaufdatum: {{expiry_date}}
Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit diese die Fotos ansehen und herunterladen können.
WICHTIG: Diese Galerie läuft am {{expiry_date}} ab. Nach diesem Datum werden die Fotos archiviert und sind nicht mehr zugänglich.
Mit freundlichen Grüßen,
Ihr Foto-Sharing-Team`
});
// Update expiration_warning template with proper German translation
await knex('email_templates')
.where('template_key', 'expiration_warning')
.update({
subject_de: 'Ihre Fotogalerie läuft bald ab',
body_html_de: `<h2>Galerie läuft bald ab</h2>
<p>Liebe(r) {{host_name}},</p>
<p>Ihre Fotogalerie "{{event_name}}" läuft in <strong>{{days_remaining}} Tagen</strong> ab.</p>
<p>Nach Ablauf wird die Galerie archiviert und ist für Gäste nicht mehr zugänglich. Bitte stellen Sie sicher, dass alle gewünschten Fotos heruntergeladen wurden.</p>
<p><strong>Ablaufdatum:</strong> {{expiry_date}}</p>
<a href="{{gallery_link}}" style="display: inline-block; padding: 12px 30px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 500; margin: 20px 0;">Galerie jetzt besuchen</a>
<p style="background-color: #FEE2E2; padding: 15px; border-radius: 5px; border-left: 4px solid #EF4444;">
<strong>Erinnerung:</strong> Nach dem {{expiry_date}} können Ihre Gäste nicht mehr auf die Galerie zugreifen.
</p>
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
body_text_de: `Galerie läuft bald ab
Liebe(r) {{host_name}},
Ihre Fotogalerie "{{event_name}}" läuft in {{days_remaining}} Tagen ab.
Nach Ablauf wird die Galerie archiviert und ist für Gäste nicht mehr zugänglich. Bitte stellen Sie sicher, dass alle gewünschten Fotos heruntergeladen wurden.
Ablaufdatum: {{expiry_date}}
Galerie-Link: {{gallery_link}}
ERINNERUNG: Nach dem {{expiry_date}} können Ihre Gäste nicht mehr auf die Galerie zugreifen.
Mit freundlichen Grüßen,
Ihr Foto-Sharing-Team`
});
// Update gallery_expired template with proper German translation
await knex('email_templates')
.where('template_key', 'gallery_expired')
.update({
subject_de: 'Ihre Fotogalerie {{event_name}} ist abgelaufen',
body_html_de: `<h2>Galerie abgelaufen</h2>
<p>Liebe(r) {{host_name}},</p>
<p>Ihre Fotogalerie "{{event_name}}" ist am {{expiry_date}} abgelaufen und wurde archiviert.</p>
<p>Die Galerie ist nicht mehr für Gäste zugänglich. Alle Fotos wurden sicher in unserem Archivsystem gespeichert.</p>
<p>Wenn Sie wieder Zugriff auf die archivierten Fotos benötigen, wenden Sie sich bitte an unseren Support:</p>
<p style="background-color: #F3F4F6; padding: 15px; border-radius: 5px;">
<strong>Kontakt:</strong><br>
E-Mail: <a href="mailto:{{admin_email}}" style="color: #5C8762;">{{admin_email}}</a><br>
{{#if support_phone}}Telefon: {{support_phone}}{{/if}}
</p>
<p>Vielen Dank für die Nutzung unseres Foto-Sharing-Services!</p>
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
body_text_de: `Galerie abgelaufen
Liebe(r) {{host_name}},
Ihre Fotogalerie "{{event_name}}" ist am {{expiry_date}} abgelaufen und wurde archiviert.
Die Galerie ist nicht mehr für Gäste zugänglich. Alle Fotos wurden sicher in unserem Archivsystem gespeichert.
Wenn Sie wieder Zugriff auf die archivierten Fotos benötigen, wenden Sie sich bitte an unseren Support:
E-Mail: {{admin_email}}
{{#if support_phone}}Telefon: {{support_phone}}{{/if}}
Vielen Dank für die Nutzung unseres Foto-Sharing-Services!
Mit freundlichen Grüßen,
Ihr Foto-Sharing-Team`
});
// Update archive_complete template with proper German translation
await knex('email_templates')
.where('template_key', 'archive_complete')
.update({
subject_de: 'Archivierung abgeschlossen: {{event_name}}',
body_html_de: `<h2>Archivierung abgeschlossen</h2>
<p>Liebe(r) {{host_name}},</p>
<p>Die Fotogalerie "{{event_name}}" wurde erfolgreich archiviert.</p>
<p><strong>Archiv-Details:</strong></p>
<ul>
<li>Archivgröße: {{archive_size}}</li>
<li>Archivierungsdatum: {{archive_date}}</li>
<li>Anzahl der Fotos: {{photo_count}}</li>
</ul>
<p>Das Archiv wird sicher in unserem System aufbewahrt. Bei Bedarf können Sie sich an unseren Support wenden, um Zugriff auf die archivierten Fotos zu erhalten.</p>
<p style="background-color: #F0FDF4; padding: 15px; border-radius: 5px; border-left: 4px solid #22C55E;">
<strong>✓ Erfolgreich archiviert:</strong> Ihre Fotos sind sicher gespeichert und können bei Bedarf wiederhergestellt werden.
</p>
<p>Kontakt für Archivzugriff:<br>
E-Mail: <a href="mailto:{{admin_email}}" style="color: #5C8762;">{{admin_email}}</a></p>
<p>Mit freundlichen Grüßen,<br>Ihr Foto-Sharing-Team</p>`,
body_text_de: `Archivierung abgeschlossen
Liebe(r) {{host_name}},
Die Fotogalerie "{{event_name}}" wurde erfolgreich archiviert.
Archiv-Details:
- Archivgröße: {{archive_size}}
- Archivierungsdatum: {{archive_date}}
- Anzahl der Fotos: {{photo_count}}
Das Archiv wird sicher in unserem System aufbewahrt. Bei Bedarf können Sie sich an unseren Support wenden, um Zugriff auf die archivierten Fotos zu erhalten.
✓ ERFOLGREICH ARCHIVIERT: Ihre Fotos sind sicher gespeichert und können bei Bedarf wiederhergestellt werden.
Kontakt für Archivzugriff:
E-Mail: {{admin_email}}
Mit freundlichen Grüßen,
Ihr Foto-Sharing-Team`
});
// Also update the non-language-specific fields to match German for consistency
await knex('email_templates')
.where('template_key', 'gallery_created')
.update({
subject: knex.raw('subject_de'),
body_html: knex.raw('body_html_de'),
body_text: knex.raw('body_text_de')
});
await knex('email_templates')
.where('template_key', 'expiration_warning')
.update({
subject: knex.raw('subject_de'),
body_html: knex.raw('body_html_de'),
body_text: knex.raw('body_text_de')
});
await knex('email_templates')
.where('template_key', 'gallery_expired')
.update({
subject: knex.raw('subject_de'),
body_html: knex.raw('body_html_de'),
body_text: knex.raw('body_text_de')
});
await knex('email_templates')
.where('template_key', 'archive_complete')
.update({
subject: knex.raw('subject_de'),
body_html: knex.raw('body_html_de'),
body_text: knex.raw('body_text_de')
});
};
exports.down = async function(knex) {
// Revert to previous German translations
// This is a simplified rollback - in production you might want to store the old values
await knex('email_templates')
.where('template_key', 'gallery_created')
.update({
subject: knex.raw('subject_en'),
body_html: knex.raw('body_html_en'),
body_text: knex.raw('body_text_en')
});
await knex('email_templates')
.where('template_key', 'expiration_warning')
.update({
subject: knex.raw('subject_en'),
body_html: knex.raw('body_html_en'),
body_text: knex.raw('body_text_en')
});
await knex('email_templates')
.where('template_key', 'gallery_expired')
.update({
subject: knex.raw('subject_en'),
body_html: knex.raw('body_html_en'),
body_text: knex.raw('body_text_en')
});
await knex('email_templates')
.where('template_key', 'archive_complete')
.update({
subject: knex.raw('subject_en'),
body_html: knex.raw('body_html_en'),
body_text: knex.raw('body_text_en')
});
};
@@ -0,0 +1,41 @@
exports.up = async function(knex) {
// Add language column to events table if it doesn't exist
const hasLanguageInEvents = await knex.schema.hasColumn('events', 'language');
if (!hasLanguageInEvents) {
await knex.schema.alterTable('events', function(table) {
table.string('language', 5).defaultTo('en');
});
}
// Add default_language to email_configs if it doesn't exist
const hasDefaultLanguage = await knex.schema.hasColumn('email_configs', 'default_language');
if (!hasDefaultLanguage) {
await knex.schema.alterTable('email_configs', function(table) {
table.string('default_language', 5).defaultTo('en');
});
}
// Set default language to German for the existing email config
await knex('email_configs')
.update({
default_language: 'de'
});
};
exports.down = async function(knex) {
// Remove language column from events table
const hasLanguageInEvents = await knex.schema.hasColumn('events', 'language');
if (hasLanguageInEvents) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('language');
});
}
// Remove default_language from email_configs
const hasDefaultLanguage = await knex.schema.hasColumn('email_configs', 'default_language');
if (hasDefaultLanguage) {
await knex.schema.alterTable('email_configs', function(table) {
table.dropColumn('default_language');
});
}
};
@@ -0,0 +1,63 @@
exports.up = async function(knex) {
// Add rate limit settings to app_settings
const rateLimitSettings = [
{
setting_key: 'rate_limit_enabled',
setting_value: JSON.stringify(true),
setting_type: 'security'
},
{
setting_key: 'rate_limit_window_minutes',
setting_value: JSON.stringify(15),
setting_type: 'security'
},
{
setting_key: 'rate_limit_max_requests',
setting_value: JSON.stringify(1000),
setting_type: 'security'
},
{
setting_key: 'rate_limit_auth_max_requests',
setting_value: JSON.stringify(5),
setting_type: 'security'
},
{
setting_key: 'rate_limit_skip_authenticated',
setting_value: JSON.stringify(true),
setting_type: 'security'
},
{
setting_key: 'rate_limit_public_endpoints_only',
setting_value: JSON.stringify(false),
setting_type: 'security'
}
];
// Insert settings if they don't exist
for (const setting of rateLimitSettings) {
const exists = await knex('app_settings')
.where('setting_key', setting.setting_key)
.first();
if (!exists) {
await knex('app_settings').insert({
...setting,
updated_at: knex.fn.now()
});
}
}
};
exports.down = async function(knex) {
// Remove rate limit settings
await knex('app_settings')
.whereIn('setting_key', [
'rate_limit_enabled',
'rate_limit_window_minutes',
'rate_limit_max_requests',
'rate_limit_auth_max_requests',
'rate_limit_skip_authenticated',
'rate_limit_public_endpoints_only'
])
.del();
};
@@ -0,0 +1,307 @@
exports.up = async function(knex) {
// Update English templates to match the quality and content of German templates
// 1. Gallery Created - Match German version with proper styling and conditionals
await knex('email_templates')
.where('template_key', 'gallery_created')
.update({
subject_en: 'Your photo gallery is ready',
body_html_en: `
<h2>Hello {{host_name}},</h2>
<p>Your photo gallery <strong>{{event_name}}</strong> for {{event_date}} has been successfully created and is now online!</p>
{{#if welcome_message}}
<div style="background-color: #f0f8ff; border-left: 4px solid #5C8762; padding: 15px; margin: 20px 0; border-radius: 4px;">
<p style="margin: 0;"><strong>Personal message from your photographer:</strong></p>
<p style="margin: 10px 0 0 0;">{{welcome_message}}</p>
</div>
{{/if}}
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0;">Your access data:</h3>
<ul style="list-style: none; padding: 0;">
<li style="margin-bottom: 10px;"><strong>Gallery link:</strong> <a href="{{gallery_link}}" style="color: #5C8762;">{{gallery_link}}</a></li>
<li style="margin-bottom: 10px;"><strong>Password:</strong> {{gallery_password}}</li>
</ul>
</div>
<div style="text-align: center; margin: 30px 0;">
<a href="{{gallery_link}}" style="display: inline-block; padding: 12px 30px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px; font-weight: 500;">View Gallery</a>
</div>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; color: #856404; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>Important:</strong> Your gallery will be available until <strong>{{expiry_date}}</strong>. After this date, the photos will be archived and will only be available upon request.</p>
</div>
<p>We hope you enjoy your photos!</p>
<p>Best regards,<br>
Your Photo Sharing Team</p>`,
body_text_en: `Hello {{host_name}},
Your photo gallery "{{event_name}}" for {{event_date}} has been successfully created and is now online!
{{#if welcome_message}}
Personal message from your photographer:
{{welcome_message}}
{{/if}}
Your access data:
- Gallery link: {{gallery_link}}
- Password: {{gallery_password}}
Important: Your gallery will be available until {{expiry_date}}. After this date, the photos will be archived and will only be available upon request.
We hope you enjoy your photos!
Best regards,
Your Photo Sharing Team`
});
// 2. Expiration Warning - Match German version with urgency and styling
await knex('email_templates')
.where('template_key', 'expiration_warning')
.update({
subject_en: 'Your photo gallery expires soon',
body_html_en: `
<h2>Hello {{host_name}},</h2>
<p>Your photo gallery <strong>{{event_name}}</strong> will expire in <strong style="color: #e74c3c; font-size: 18px;">{{days_remaining}} days</strong>!</p>
<div style="background-color: #fee; border: 1px solid #fcc; color: #c33; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0; font-weight: bold; font-size: 16px;">⚠️ Important Notice</p>
<p style="margin: 10px 0 0 0;">After {{expiry_date}}, your gallery will no longer be accessible online. The photos will be archived and will only be available upon special request.</p>
</div>
<p><strong>Don't miss out download your photos now!</strong></p>
<div style="text-align: center; margin: 30px 0;">
<a href="{{gallery_link}}" style="display: inline-block; padding: 14px 35px; background-color: #e74c3c; color: white; text-decoration: none; border-radius: 5px; font-weight: 600; font-size: 16px;">Visit Gallery Now</a>
</div>
<div style="background-color: #f9f9f9; padding: 15px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0;"><strong>Quick reminder of your access data:</strong></p>
<ul style="list-style: none; padding: 0; margin: 10px 0 0 0;">
<li>Gallery link: <a href="{{gallery_link}}" style="color: #5C8762;">{{gallery_link}}</a></li>
<li>Password: {{gallery_password}}</li>
</ul>
</div>
<p>If you have any questions, please don't hesitate to contact us.</p>
<p>Best regards,<br>
Your Photo Sharing Team</p>`,
body_text_en: `Hello {{host_name}},
Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days!
⚠️ Important Notice
After {{expiry_date}}, your gallery will no longer be accessible online. The photos will be archived and will only be available upon special request.
Don't miss out download your photos now!
Quick reminder of your access data:
- Gallery link: {{gallery_link}}
- Password: {{gallery_password}}
If you have any questions, please don't hesitate to contact us.
Best regards,
Your Photo Sharing Team`
});
// 3. Gallery Expired - Match German version with contact information
await knex('email_templates')
.where('template_key', 'gallery_expired')
.update({
subject_en: 'Your photo gallery has expired',
body_html_en: `
<h2>Hello {{host_name}},</h2>
<p>Your photo gallery <strong>{{event_name}}</strong> expired on {{expiry_date}} and is no longer accessible online.</p>
<div style="background-color: #f9f9f9; border-left: 4px solid #5C8762; padding: 20px; margin: 20px 0; border-radius: 4px;">
<h3 style="margin-top: 0;">Your photos are safely archived</h3>
<p>Don't worry your photos have been securely archived and are not lost. If you need access to your photos, please contact us:</p>
<ul style="list-style: none; padding: 0;">
<li style="margin-bottom: 8px;">📧 Email: <a href="mailto:{{support_email}}" style="color: #5C8762;">{{support_email}}</a></li>
{{#if support_phone}}
<li>📞 Phone: {{support_phone}}</li>
{{/if}}
</ul>
</div>
<p>Please have the following information ready when contacting us:</p>
<ul>
<li>Event name: {{event_name}}</li>
<li>Event date: {{event_date}}</li>
<li>Expiry date: {{expiry_date}}</li>
</ul>
<p>We'll be happy to help you access your archived photos.</p>
<p>Best regards,<br>
Your Photo Sharing Team</p>`,
body_text_en: `Hello {{host_name}},
Your photo gallery "{{event_name}}" expired on {{expiry_date}} and is no longer accessible online.
Your photos are safely archived
Don't worry your photos have been securely archived and are not lost. If you need access to your photos, please contact us:
📧 Email: {{support_email}}
{{#if support_phone}}📞 Phone: {{support_phone}}{{/if}}
Please have the following information ready when contacting us:
- Event name: {{event_name}}
- Event date: {{event_date}}
- Expiry date: {{expiry_date}}
We'll be happy to help you access your archived photos.
Best regards,
Your Photo Sharing Team`
});
// 4. Archive Complete - Match German version with success message and details
await knex('email_templates')
.where('template_key', 'archive_complete')
.update({
subject_en: 'Your photo gallery has been successfully archived',
body_html_en: `
<h2>Hello {{host_name}},</h2>
<p>Your photo gallery <strong>{{event_name}}</strong> has been successfully archived.</p>
<div style="background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0; font-weight: bold;">✅ Archive successfully created</p>
<p style="margin: 10px 0 0 0;">Your photos are now safely stored in our archive.</p>
</div>
<div style="background-color: #f9f9f9; padding: 20px; border-radius: 8px; margin: 20px 0;">
<h3 style="margin-top: 0;">Archive details:</h3>
<ul style="list-style: none; padding: 0;">
<li style="margin-bottom: 8px;"><strong>Event:</strong> {{event_name}}</li>
<li style="margin-bottom: 8px;"><strong>Archive date:</strong> {{archive_date}}</li>
<li style="margin-bottom: 8px;"><strong>Number of photos:</strong> {{photo_count}}</li>
<li><strong>Archive size:</strong> {{archive_size}}</li>
</ul>
</div>
<p>If you need access to your archived photos in the future, please contact us at:</p>
<p style="margin-left: 20px;">
📧 <a href="mailto:{{support_email}}" style="color: #5C8762;">{{support_email}}</a><br>
{{#if support_phone}}📞 {{support_phone}}{{/if}}
</p>
<p>Thank you for using our photo sharing service!</p>
<p>Best regards,<br>
Your Photo Sharing Team</p>`,
body_text_en: `Hello {{host_name}},
Your photo gallery "{{event_name}}" has been successfully archived.
✅ Archive successfully created
Your photos are now safely stored in our archive.
Archive details:
- Event: {{event_name}}
- Archive date: {{archive_date}}
- Number of photos: {{photo_count}}
- Archive size: {{archive_size}}
If you need access to your archived photos in the future, please contact us at:
📧 {{support_email}}
{{#if support_phone}}📞 {{support_phone}}{{/if}}
Thank you for using our photo sharing service!
Best regards,
Your Photo Sharing Team`
});
// 5. Test Email - Update to match German style
await knex('email_templates')
.where('template_key', 'test_email')
.update({
subject_en: 'Test Email - Photo Sharing Platform',
body_html_en: `
<h2>Test Email</h2>
<p>This is a test email from your photo sharing platform.</p>
<div style="background-color: #d4edda; border: 1px solid #c3e6cb; color: #155724; padding: 15px; border-radius: 4px; margin: 20px 0;">
<p style="margin: 0;"><strong>✅ Email configuration successful!</strong></p>
<p style="margin: 10px 0 0 0;">Your email settings have been configured correctly and emails can be sent.</p>
</div>
<div style="background-color: #f9f9f9; padding: 15px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0;"><strong>Configuration details:</strong></p>
<ul style="margin: 10px 0 0 0;">
<li>Timestamp: {{timestamp}}</li>
<li>Sender: {{from_email}}</li>
</ul>
</div>
<p>Best regards,<br>
Your Photo Sharing Team</p>`,
body_text_en: `Test Email
This is a test email from your photo sharing platform.
✅ Email configuration successful!
Your email settings have been configured correctly and emails can be sent.
Configuration details:
- Timestamp: {{timestamp}}
- Sender: {{from_email}}
Best regards,
Your Photo Sharing Team`
});
};
exports.down = async function(knex) {
// Revert to previous simpler English templates
await knex('email_templates')
.where('template_key', 'gallery_created')
.update({
subject_en: 'Your Photo Gallery is Ready',
body_html_en: '<h2>Hello,</h2><p>Your photo gallery "{{event_name}}" has been created.</p><p><strong>Access Link:</strong> <a href="{{gallery_link}}">{{gallery_link}}</a></p><p><strong>Password:</strong> {{gallery_password}}</p><p>The gallery will be available until {{expiry_date}}.</p>',
body_text_en: 'Your photo gallery "{{event_name}}" has been created. Access Link: {{gallery_link}} Password: {{gallery_password}} The gallery will be available until {{expiry_date}}.'
});
await knex('email_templates')
.where('template_key', 'expiration_warning')
.update({
subject_en: 'Gallery Expires in {{days_remaining}} Days',
body_html_en: '<h2>Reminder</h2><p>Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days.</p><p>Please download your photos before {{expiry_date}}.</p><p><a href="{{gallery_link}}">Access Gallery</a></p>',
body_text_en: 'Your photo gallery "{{event_name}}" will expire in {{days_remaining}} days. Please download your photos before {{expiry_date}}. Access Gallery: {{gallery_link}}'
});
await knex('email_templates')
.where('template_key', 'gallery_expired')
.update({
subject_en: 'Gallery Expired',
body_html_en: '<h2>Gallery Expired</h2><p>Your photo gallery "{{event_name}}" has expired and is no longer accessible.</p><p>If you need access to your photos, please contact support.</p>',
body_text_en: 'Your photo gallery "{{event_name}}" has expired and is no longer accessible. If you need access to your photos, please contact support.'
});
await knex('email_templates')
.where('template_key', 'archive_complete')
.update({
subject_en: 'Gallery Archived',
body_html_en: '<h2>Archive Complete</h2><p>Your gallery "{{event_name}}" has been archived.</p><p>Archive size: {{archive_size}}</p>',
body_text_en: 'Your gallery "{{event_name}}" has been archived. Archive size: {{archive_size}}'
});
await knex('email_templates')
.where('template_key', 'test_email')
.update({
subject_en: 'Test Email',
body_html_en: '<p>This is a test email sent at {{timestamp}}.</p>',
body_text_en: 'This is a test email sent at {{timestamp}}.'
});
};
+49 -3
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.0.23",
"version": "1.0.68",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.0.23",
"version": "1.0.68",
"dependencies": {
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
@@ -19,6 +19,7 @@
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
"form-data": "^4.0.3",
"handlebars": "^4.7.8",
"helmet": "^7.0.0",
"i18next": "^25.3.1",
"i18next-browser-languagedetector": "^8.2.0",
@@ -3995,6 +3996,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.2",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@@ -5985,6 +6007,12 @@
"node": ">= 0.6"
}
},
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
"node_modules/node-abi": {
"version": "3.75.0",
"resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.75.0.tgz",
@@ -7606,7 +7634,6 @@
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -8161,6 +8188,19 @@
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
"license": "BSD-2-Clause",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/undefsafe": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz",
@@ -8412,6 +8452,12 @@
"node": ">=0.10.0"
}
},
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"license": "MIT"
},
"node_modules/wrap-ansi": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.23",
"version": "1.0.68",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -8,6 +8,7 @@
"dev": "nodemon server.js",
"migrate": "node migrations/run-migrations.js",
"migrate:safe": "node migrations/run-migrations-safe.js",
"fix-temp-photos": "node scripts/fix-temp-photos.js",
"test": "jest",
"lint": "eslint src/"
},
@@ -23,6 +24,7 @@
"express-rate-limit": "^6.7.0",
"express-validator": "^7.0.1",
"form-data": "^4.0.3",
"handlebars": "^4.7.8",
"helmet": "^7.0.0",
"i18next": "^25.3.1",
"i18next-browser-languagedetector": "^8.2.0",
+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();
+66
View File
@@ -0,0 +1,66 @@
const { db } = require('../src/database/db');
async function checkEmailTemplates() {
try {
console.log('=== Email Templates Check ===\n');
// 1. Check table columns
console.log('1. Checking email_templates table structure...');
// Check which columns exist
const columnChecks = [
'subject', 'subject_en', 'subject_de',
'body_html', 'body_html_en', 'body_html_de',
'body_text', 'body_text_en', 'body_text_de'
];
const existingColumns = [];
for (const col of columnChecks) {
const exists = await db.schema.hasColumn('email_templates', col);
if (exists) existingColumns.push(col);
}
console.log(' Existing columns:', existingColumns.join(', '));
// 2. Get all templates
console.log('\n2. Current email templates:');
const templates = await db('email_templates').select('*');
for (const template of templates) {
console.log(`\n Template: ${template.template_key}`);
console.log(' -------------------');
// Check which fields have content
const fields = ['subject', 'subject_en', 'subject_de',
'body_html', 'body_html_en', 'body_html_de',
'body_text', 'body_text_en', 'body_text_de'];
for (const field of fields) {
if (template[field]) {
const preview = template[field].substring(0, 50) + '...';
console.log(` ${field}: ${preview}`);
}
}
// Check for German translations
const hasGermanSubject = template.subject_de || template.body_html_de;
console.log(` Has German translation: ${hasGermanSubject ? 'YES' : 'NO'}`);
}
// 3. Summary
console.log('\n3. Summary:');
const totalTemplates = templates.length;
const templatesWithGerman = templates.filter(t => t.subject_de || t.body_html_de).length;
console.log(` Total templates: ${totalTemplates}`);
console.log(` Templates with German: ${templatesWithGerman}`);
console.log(` Missing German: ${totalTemplates - templatesWithGerman}`);
await db.destroy();
} catch (error) {
console.error('Error:', error);
await db.destroy();
process.exit(1);
}
}
checkEmailTemplates();
+53
View File
@@ -0,0 +1,53 @@
const { db } = require('../src/database/db');
async function checkGermanTemplates() {
try {
console.log('=== German Email Template Content Check ===\n');
const templates = await db('email_templates').select('*');
for (const template of templates) {
console.log(`\nTemplate: ${template.template_key}`);
console.log('=====================================');
// Check German subject
console.log('\nGERMAN SUBJECT:');
console.log(template.subject_de || 'MISSING');
// Check if German HTML body has English content
console.log('\nGERMAN HTML BODY:');
const germanHtml = template.body_html_de || '';
// Check for English phrases in German template
const englishPhrases = [
'Dear', 'Gallery', 'has been', 'Your photo', 'successfully',
'Details:', 'Link:', 'Password:', 'Expires:', 'Event Date:',
'Thank you', 'Best regards', 'View Gallery', 'days'
];
const foundEnglish = englishPhrases.filter(phrase =>
germanHtml.toLowerCase().includes(phrase.toLowerCase())
);
if (foundEnglish.length > 0) {
console.log('⚠️ Found English phrases in German template:', foundEnglish.join(', '));
}
// Show first 500 chars of German HTML
console.log(germanHtml.substring(0, 500) + '...\n');
// Check German text body
console.log('GERMAN TEXT BODY:');
const germanText = template.body_text_de || '';
console.log(germanText.substring(0, 300) + '...\n');
}
await db.destroy();
} catch (error) {
console.error('Error:', error);
await db.destroy();
process.exit(1);
}
}
checkGermanTemplates();
+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!');
+146
View File
@@ -0,0 +1,146 @@
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 debugEmailQueue() {
try {
logger.info('=== Email Queue Debug Report ===\n');
// 1. Count exactly like the admin dashboard does
logger.info('1. Admin Dashboard Query (ALL pending, no retry filter):');
const [adminCount] = await db('email_queue').where('status', 'pending').count('* as count');
logger.info(` Pending emails (admin dashboard view): ${adminCount.count}\n`);
// 2. Count like the email processor does
logger.info('2. Email Processor Query (pending with retry_count < 3):');
const [processorCount] = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
.count('* as count');
logger.info(` Pending emails (processor view): ${processorCount.count}\n`);
// 3. Show the discrepancy
logger.info('3. Discrepancy Analysis:');
if (adminCount.count !== processorCount.count) {
logger.info(` ⚠️ DISCREPANCY FOUND!`);
logger.info(` Admin shows: ${adminCount.count}`);
logger.info(` Processor will process: ${processorCount.count}`);
logger.info(` Difference: ${adminCount.count - processorCount.count} email(s)\n`);
// Find the problematic emails
logger.info('4. Emails with retry_count >= 3 (still pending):');
const stuckEmails = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '>=', 3)
.select('*');
if (stuckEmails.length > 0) {
logger.info(` Found ${stuckEmails.length} stuck email(s):\n`);
stuckEmails.forEach((email, index) => {
logger.info(` Email ${index + 1}:`);
logger.info(` ID: ${email.id}`);
logger.info(` Type: ${email.email_type}`);
logger.info(` Recipient: ${email.recipient_email}`);
logger.info(` Status: ${email.status}`);
logger.info(` Retry Count: ${email.retry_count} ⚠️`);
logger.info(` Created: ${email.created_at}`);
logger.info(` Last Error: ${email.error_message || 'None'}\n`);
});
}
} else {
logger.info(` ✅ No discrepancy - counts match\n`);
}
// 5. Show ALL pending emails with details
logger.info('5. ALL Pending Emails (regardless of retry count):');
const allPending = await db('email_queue')
.where('status', 'pending')
.orderBy('retry_count', 'desc')
.orderBy('created_at', 'asc');
if (allPending.length > 0) {
allPending.forEach((email, index) => {
const willProcess = email.retry_count < 3;
logger.info(`\n Email ${index + 1}: ${willProcess ? '✅ WILL PROCESS' : '❌ STUCK (max retries)'}`);
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(` Retry Count: ${email.retry_count}/3`);
logger.info(` Created: ${email.created_at}`);
logger.info(` Scheduled: ${email.scheduled_at}`);
if (email.error_message) {
logger.info(` Last Error: ${email.error_message}`);
}
});
} else {
logger.info(' No pending emails found');
}
// 6. Show counts by status
logger.info('\n\n6. Email Queue Summary by Status:');
const statusCounts = await db('email_queue')
.select('status')
.count('* as count')
.groupBy('status')
.orderBy('status');
statusCounts.forEach(row => {
logger.info(` ${row.status}: ${row.count}`);
});
// 7. Failed emails summary
logger.info('\n7. Failed Emails Summary:');
const failedSummary = await db('email_queue')
.where('status', 'failed')
.select('retry_count')
.count('* as count')
.groupBy('retry_count')
.orderBy('retry_count');
if (failedSummary.length > 0) {
failedSummary.forEach(row => {
logger.info(` Retry count ${row.retry_count}: ${row.count} email(s)`);
});
} else {
logger.info(' No failed emails');
}
// 8. Recommendations
logger.info('\n\n=== RECOMMENDATIONS ===');
if (adminCount.count > processorCount.count) {
logger.info('\n❗ You have emails stuck with retry_count >= 3');
logger.info(' These emails will NOT be processed automatically.');
logger.info('\n To fix this, you can:');
logger.info(' 1. Reset retry count: UPDATE email_queue SET retry_count = 0 WHERE status = \'pending\' AND retry_count >= 3;');
logger.info(' 2. Mark as failed: UPDATE email_queue SET status = \'failed\' WHERE status = \'pending\' AND retry_count >= 3;');
logger.info(' 3. Delete them: DELETE FROM email_queue WHERE status = \'pending\' AND retry_count >= 3;');
}
const anyPending = adminCount.count > 0;
if (anyPending && processorCount.count === 0) {
logger.info('\n❗ All pending emails have exceeded retry limit');
logger.info(' The email processor will not attempt to send them.');
} else if (anyPending && processorCount.count > 0) {
logger.info('\n✅ Email processor should process the pending emails on next run');
logger.info(' Make sure the email processor service is running.');
}
logger.info('\n=== Debug report complete ===');
} catch (error) {
logger.error('Error running debug report:', error);
} finally {
await db.destroy();
process.exit(0);
}
}
// Run the debug
debugEmailQueue();
+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();
@@ -0,0 +1,88 @@
const { db } = require('../src/database/db');
async function fixFinalGermanTemplates() {
try {
console.log('Fixing remaining English words in German templates...\n');
// Get all templates
const templates = await db('email_templates').select('*');
for (const template of templates) {
let updated = false;
let updates = {};
// Fix subject_de
if (template.subject_de) {
updates.subject_de = template.subject_de;
}
// Fix body_html_de
if (template.body_html_de) {
let html = template.body_html_de;
// Replace English words with German
html = html.replace(/Gallery-Details:/g, 'Galerie-Details:');
html = html.replace(/Galerie-Details:/g, 'Galerie-Details:');
html = html.replace(/Details:/g, 'Details:');
html = html.replace(/Link:/g, 'Link:');
html = html.replace(/Gallery-Link:/g, 'Galerie-Link:');
html = html.replace(/Galerie-Link:/g, 'Galerie-Link:');
html = html.replace(/Archive-Details:/g, 'Archiv-Details:');
html = html.replace(/Archiv-Details:/g, 'Archiv-Details:');
if (html !== template.body_html_de) {
updates.body_html_de = html;
updated = true;
}
}
// Fix body_text_de
if (template.body_text_de) {
let text = template.body_text_de;
text = text.replace(/Gallery-Details:/g, 'Galerie-Details:');
text = text.replace(/Galerie-Details:/g, 'Galerie-Details:');
text = text.replace(/Details:/g, 'Details:');
text = text.replace(/Link:/g, 'Link:');
text = text.replace(/Gallery-Link:/g, 'Galerie-Link:');
text = text.replace(/Galerie-Link:/g, 'Galerie-Link:');
text = text.replace(/Archive-Details:/g, 'Archiv-Details:');
text = text.replace(/Archiv-Details:/g, 'Archiv-Details:');
if (text !== template.body_text_de) {
updates.body_text_de = text;
updated = true;
}
}
// Also update the non-language-specific fields to match German
if (template.body_html_de) {
updates.body_html = template.body_html_de;
}
if (template.body_text_de) {
updates.body_text = template.body_text_de;
}
if (template.subject_de) {
updates.subject = template.subject_de;
}
if (updated || Object.keys(updates).length > 0) {
await db('email_templates')
.where('template_key', template.template_key)
.update(updates);
console.log(`✅ Updated ${template.template_key}`);
} else {
console.log(`⏭️ No changes needed for ${template.template_key}`);
}
}
console.log('\nDone!');
await db.destroy();
} catch (error) {
console.error('Error:', error);
await db.destroy();
process.exit(1);
}
}
fixFinalGermanTemplates();
+126
View File
@@ -0,0 +1,126 @@
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 fixStuckEmails() {
try {
logger.info('=== Fix Stuck Emails Script ===\n');
// 1. Find stuck emails
logger.info('1. Finding stuck emails (pending with retry_count >= 3)...');
const stuckEmails = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '>=', 3)
.select('*');
if (stuckEmails.length === 0) {
logger.info(' ✅ No stuck emails found!');
logger.info('\n=== Script complete ===');
await db.destroy();
process.exit(0);
}
logger.info(` Found ${stuckEmails.length} stuck email(s)\n`);
// 2. Show details
logger.info('2. Stuck email details:');
stuckEmails.forEach((email, index) => {
logger.info(`\n Email ${index + 1}:`);
logger.info(` ID: ${email.id}`);
logger.info(` Type: ${email.email_type}`);
logger.info(` Recipient: ${email.recipient_email}`);
logger.info(` Retry Count: ${email.retry_count}`);
logger.info(` Last Error: ${email.error_message || 'None'}`);
});
// 3. Ask for action
logger.info('\n\n3. Choose an action:');
logger.info(' 1. Reset retry count to 0 (emails will be retried)');
logger.info(' 2. Mark as failed (emails will not be retried)');
logger.info(' 3. Delete these emails');
logger.info(' 4. Cancel (do nothing)');
// Get command line argument
const action = process.argv[2];
if (!action || !['reset', 'fail', 'delete'].includes(action)) {
logger.info('\n❗ No valid action specified');
logger.info('\nUsage:');
logger.info(' node fix-stuck-emails.js reset - Reset retry count to 0');
logger.info(' node fix-stuck-emails.js fail - Mark as failed');
logger.info(' node fix-stuck-emails.js delete - Delete stuck emails');
await db.destroy();
process.exit(1);
}
// 4. Execute action
logger.info(`\n4. Executing action: ${action.toUpperCase()}`);
const emailIds = stuckEmails.map(e => e.id);
switch (action) {
case 'reset':
await db('email_queue')
.whereIn('id', emailIds)
.update({
retry_count: 0,
error_message: null
});
logger.info(` ✅ Reset retry count for ${emailIds.length} email(s)`);
logger.info(' These emails will be processed on the next run');
break;
case 'fail':
await db('email_queue')
.whereIn('id', emailIds)
.update({
status: 'failed'
});
logger.info(` ✅ Marked ${emailIds.length} email(s) as failed`);
logger.info(' These emails will not be retried');
break;
case 'delete':
await db('email_queue')
.whereIn('id', emailIds)
.delete();
logger.info(` ✅ Deleted ${emailIds.length} email(s)`);
break;
}
// 5. Show updated counts
logger.info('\n5. Updated email queue status:');
const [pendingCount] = await db('email_queue')
.where('status', 'pending')
.count('* as count');
const [processableCount] = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
.count('* as count');
logger.info(` Total pending: ${pendingCount.count}`);
logger.info(` Processable (retry < 3): ${processableCount.count}`);
if (pendingCount.count !== processableCount.count) {
logger.info(` ⚠️ Still have ${pendingCount.count - processableCount.count} stuck email(s)`);
} else {
logger.info(' ✅ No stuck emails remaining');
}
logger.info('\n=== Script complete ===');
} catch (error) {
logger.error('Error:', error);
} finally {
await db.destroy();
process.exit(0);
}
}
// Run the fix
fixStuckEmails();
+171
View File
@@ -0,0 +1,171 @@
require('dotenv').config({ path: '../.env' });
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../src/database/db');
const { generatePhotoFilename } = require('../src/utils/filenameSanitizer');
async function fixTempPhotos() {
console.log('Starting to fix temporary photo files...\n');
try {
// Find all photos with temp_ filenames
const tempPhotos = await db('photos')
.where('filename', 'like', 'temp_%')
.orderBy('event_id', 'asc')
.orderBy('category_id', 'asc')
.orderBy('id', 'asc');
console.log(`Found ${tempPhotos.length} photos with temporary filenames\n`);
if (tempPhotos.length === 0) {
console.log('No temporary photos found. Exiting.');
return;
}
// Group photos by event and category
const grouped = {};
for (const photo of tempPhotos) {
const key = `${photo.event_id}_${photo.category_id || 'null'}`;
if (!grouped[key]) {
grouped[key] = [];
}
grouped[key].push(photo);
}
console.log(`Processing ${Object.keys(grouped).length} event/category groups...\n`);
// Process each group
for (const [key, photos] of Object.entries(grouped)) {
const [eventId, categoryIdStr] = key.split('_');
const categoryId = categoryIdStr === 'null' ? null : parseInt(categoryIdStr);
console.log(`\nProcessing Event ID: ${eventId}, Category ID: ${categoryId || 'uncategorized'}`);
console.log(`Photos in group: ${photos.length}`);
// Get event details
const event = await db('events').where({ id: eventId }).first();
if (!event) {
console.error(`Event ${eventId} not found! Skipping...`);
continue;
}
// Get category details if applicable
let category = null;
let startCounter = 1;
if (categoryId) {
category = await db('photo_categories').where({ id: categoryId }).first();
if (!category) {
console.error(`Category ${categoryId} not found! Treating as uncategorized...`);
} else {
// Get the highest counter for this category
const maxPhoto = await db('photos')
.where({ event_id: eventId, category_id: categoryId })
.whereNot('filename', 'like', 'temp_%')
.orderBy('id', 'desc')
.first();
if (maxPhoto && maxPhoto.filename) {
// Extract counter from filename
const match = maxPhoto.filename.match(/_(\d+)\.[^.]+$/);
if (match) {
startCounter = parseInt(match[1]) + 1;
}
}
}
} else {
// For uncategorized, get the highest counter
const maxPhoto = await db('photos')
.where({ event_id: eventId })
.whereNull('category_id')
.whereNot('filename', 'like', 'temp_%')
.orderBy('id', 'desc')
.first();
if (maxPhoto && maxPhoto.filename) {
const match = maxPhoto.filename.match(/_(\d+)\.[^.]+$/);
if (match) {
startCounter = parseInt(match[1]) + 1;
}
}
}
console.log(`Starting counter: ${startCounter}`);
// Process each photo in the group
let successCount = 0;
let errorCount = 0;
for (let i = 0; i < photos.length; i++) {
const photo = photos[i];
const counter = startCounter + i;
try {
// Generate new filename
const extension = path.extname(photo.filename);
const newFilename = generatePhotoFilename(
event.event_name,
category ? category.name : 'uncategorized',
counter,
extension
);
// Build full paths
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
const oldPath = path.join(storagePath, 'events/active', photo.path);
const newPath = path.join(path.dirname(oldPath), newFilename);
// Check if old file exists
try {
await fs.access(oldPath);
} catch (e) {
console.error(`File not found: ${oldPath}`);
errorCount++;
continue;
}
// Rename the file
await fs.rename(oldPath, newPath);
// Update database
const newRelativePath = path.relative(path.join(storagePath, 'events/active'), newPath);
await db('photos')
.where({ id: photo.id })
.update({
filename: newFilename,
path: newRelativePath
});
console.log(`✓ Renamed: ${photo.filename}${newFilename}`);
successCount++;
} catch (error) {
console.error(`✗ Failed to process photo ${photo.id}: ${error.message}`);
errorCount++;
}
}
// Update category counter if needed
if (category && successCount > 0) {
const newCounter = startCounter + photos.length - 1;
await db('photo_categories')
.where({ id: categoryId })
.update({ photo_counter: newCounter });
console.log(`Updated category counter to ${newCounter}`);
}
console.log(`\nGroup summary: ${successCount} successful, ${errorCount} errors`);
}
console.log('\n=== COMPLETE ===');
console.log('All temporary photos have been processed.');
} catch (error) {
console.error('Fatal error:', error);
} finally {
await db.destroy();
}
}
// Run the script
fixTempPhotos().catch(console.error);
+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);
+52
View File
@@ -0,0 +1,52 @@
/**
* Test script to verify CMS and email formatting improvements
*/
const { formatWelcomeMessage, nl2br } = require('../src/utils/formatters');
console.log('Testing CMS and Email Formatting Improvements\n');
// Test 1: Basic line break conversion
console.log('Test 1: Basic line break conversion');
const basicText = `Hello,
This is line 1.
This is line 2.
This is line 4 with an extra break.`;
console.log('Input:');
console.log(basicText);
console.log('\nOutput (nl2br):');
console.log(nl2br(basicText));
console.log('\n---\n');
// Test 2: Welcome message formatting
console.log('Test 2: Welcome message formatting');
const welcomeMessage = `Dear guests,
We're so excited to share these special moments with you!
Please note:
- Download your photos before the expiration date
- The password is case-sensitive
- Contact us if you have any issues
Thank you for being part of our special day!
Best regards,
Sarah & John`;
console.log('Input:');
console.log(welcomeMessage);
console.log('\nOutput (formatWelcomeMessage):');
console.log(formatWelcomeMessage(welcomeMessage));
console.log('\n---\n');
// Test 3: Empty and edge cases
console.log('Test 3: Edge cases');
console.log('Empty string:', formatWelcomeMessage(''));
console.log('Null:', formatWelcomeMessage(null));
console.log('Only spaces:', formatWelcomeMessage(' \n \n '));
console.log('Single line:', formatWelcomeMessage('This is a single line message'));
console.log('\nAll tests completed!');
+85
View File
@@ -0,0 +1,85 @@
const { db } = require('../src/database/db');
const { processTemplate } = require('../src/services/emailProcessor');
async function testGermanEmails() {
try {
console.log('=== Testing German Email Templates ===\n');
// Test variables
const testVars = {
host_name: 'Max Mustermann',
event_name: 'Hochzeit Schmidt',
event_date: '15.07.2024',
gallery_link: 'https://example.com/gallery/test',
gallery_password: 'test1234',
expiry_date: '15.08.2024',
days_remaining: '7',
welcome_message: 'Herzlich willkommen zu unserer Hochzeitsgalerie!',
archive_size: '250 MB',
archive_date: '16.08.2024',
photo_count: '347',
admin_email: 'support@example.com',
eventId: 1
};
const templates = await db('email_templates').select('*');
for (const template of templates) {
console.log(`\n========== ${template.template_key.toUpperCase()} ==========`);
// Process German version
const germanResult = await processGermanTemplate(template, testVars);
console.log('\n--- GERMAN VERSION ---');
console.log('Subject:', germanResult.subject);
console.log('\nHTML Preview (first 500 chars):');
console.log(germanResult.htmlBody.substring(0, 500) + '...\n');
// Check for any remaining English text
const englishWords = ['Dear', 'Gallery', 'Details:', 'Link:', 'Password:', 'days', 'Thank you'];
const foundEnglish = englishWords.filter(word =>
germanResult.htmlBody.includes(word) || germanResult.subject.includes(word)
);
if (foundEnglish.length > 0) {
console.log('⚠️ WARNING: Found English words:', foundEnglish.join(', '));
} else {
console.log('✅ No English words found in German template');
}
}
await db.destroy();
} catch (error) {
console.error('Error:', error);
await db.destroy();
process.exit(1);
}
}
async function processGermanTemplate(template, variables) {
// Process template as German
const subjectField = 'subject_de';
const htmlField = 'body_html_de';
const textField = 'body_text_de';
let subject = template[subjectField] || template.subject || '';
let htmlBody = template[htmlField] || template.body_html || '';
let textBody = template[textField] || template.body_text || '';
// Replace variables
Object.keys(variables).forEach(key => {
const regex = new RegExp(`{{${key}}}`, 'g');
subject = subject.replace(regex, variables[key]);
htmlBody = htmlBody.replace(regex, variables[key]);
textBody = textBody.replace(regex, variables[key]);
});
// Handle conditionals (simplified)
htmlBody = htmlBody.replace(/{{#if welcome_message}}[\s\S]*?{{\/if}}/g, (match) => {
return variables.welcome_message ? match.replace(/{{#if welcome_message}}|{{\/if}}/g, '') : '';
});
return { subject, htmlBody, textBody };
}
testGermanEmails();
+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);
});
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env node
/**
* Test script to verify security logging is working correctly
* Run with: node scripts/test-security-logging.js
*/
require('dotenv').config({ path: '../.env' });
const logger = require('../src/utils/logger');
console.log('Testing Security Logging...\n');
// Test 1: Basic logging
console.log('1. Testing basic logging levels:');
logger.info('Test info message', { test: true });
logger.warn('Test warning message', { test: true });
logger.error('Test error message', { test: true });
// Test 2: Security event logging
console.log('\n2. Testing security event logging:');
// Rate limit exceeded
logger.warn('Rate limit exceeded', {
ip: '192.168.1.100',
path: '/api/admin/login',
method: 'POST',
authenticated: false,
userAgent: 'Mozilla/5.0 Test',
timestamp: new Date().toISOString(),
rateLimitInfo: {
limit: 5,
current: 6,
remaining: 0,
resetTime: new Date(Date.now() + 900000).toISOString()
}
});
// Auth rate limit
logger.warn('Auth rate limit exceeded', {
ip: '192.168.1.101',
path: '/api/auth/admin/login',
method: 'POST',
userAgent: 'Mozilla/5.0 Test',
authType: 'admin',
timestamp: new Date().toISOString()
});
// Failed login
logger.warn('Failed login attempt', {
username: 'testuser',
ip: '192.168.1.102',
userAgent: 'Mozilla/5.0 Test',
reason: 'invalid_credentials',
timestamp: new Date().toISOString()
});
// JWT validation failure
logger.warn('JWT validation failed', {
ip: '192.168.1.103',
path: '/api/admin/events',
method: 'GET',
userAgent: 'Mozilla/5.0 Test',
error: 'TokenExpiredError',
message: 'jwt expired',
timestamp: new Date().toISOString()
});
// Account lockout
logger.warn('Login attempt on locked account', {
username: 'lockeduser',
ip: '192.168.1.104',
remainingLockTime: 1200,
timestamp: new Date().toISOString()
});
// Suspicious activity
logger.warn('Suspicious login activity detected', {
username: 'suspicioususer',
ips: ['192.168.1.105', '192.168.1.106', '192.168.1.107'],
timeWindow: '15 minutes',
timestamp: new Date().toISOString()
});
console.log('\n3. Check log files:');
console.log('- logs/security.log - Should contain all security warnings');
console.log('- logs/error.log - Should contain error messages');
console.log('- logs/combined.log - Should contain all messages');
console.log('\n✅ Security logging test complete!');
console.log('Review the log files to ensure all events are properly captured.');
// Give logger time to flush
setTimeout(() => {
process.exit(0);
}, 1000);
+110
View File
@@ -0,0 +1,110 @@
const { db } = require('../src/database/db');
async function verifyTemplateEquality() {
try {
console.log('Verifying template equality between German and English versions...\n');
const templates = await db('email_templates').select('*');
for (const template of templates) {
console.log(`\n=== ${template.template_key.toUpperCase()} ===`);
// Check subject length similarity
const subjectEnLength = template.subject_en?.length || 0;
const subjectDeLength = template.subject_de?.length || 0;
console.log(`Subject length - EN: ${subjectEnLength}, DE: ${subjectDeLength}`);
// Check HTML content features
const htmlEn = template.body_html_en || '';
const htmlDe = template.body_html_de || '';
// Check for key features in both versions
const features = [
{ name: 'Handlebars conditionals', pattern: /{{#if/g },
{ name: 'Styled divs', pattern: /style="/g },
{ name: 'Background colors', pattern: /background-color:/g },
{ name: 'Buttons/CTAs', pattern: /<a.*style.*background-color.*>/g },
{ name: 'Icons/Emojis', pattern: /[📧📞✅⚠️]/g },
{ name: 'Lists', pattern: /<ul/g },
{ name: 'Strong emphasis', pattern: /<strong>/g }
];
console.log('\nFeature comparison:');
for (const feature of features) {
const enCount = (htmlEn.match(feature.pattern) || []).length;
const deCount = (htmlDe.match(feature.pattern) || []).length;
const status = enCount === deCount ? '✅' : '❌';
console.log(`${status} ${feature.name}: EN=${enCount}, DE=${deCount}`);
}
// Check text content length
const textEn = template.body_text_en || '';
const textDe = template.body_text_de || '';
console.log(`\nText content length - EN: ${textEn.length}, DE: ${textDe.length}`);
// Check for specific variables usage
const variables = [
'host_name', 'event_name', 'event_date', 'gallery_link',
'gallery_password', 'expiry_date', 'welcome_message',
'days_remaining', 'support_email', 'support_phone',
'archive_date', 'photo_count', 'archive_size'
];
const missingInEn = [];
const missingInDe = [];
for (const variable of variables) {
const varPattern = new RegExp(`{{${variable}}}`, 'g');
const inEn = varPattern.test(htmlEn) || varPattern.test(textEn);
const inDe = varPattern.test(htmlDe) || varPattern.test(textDe);
if (inDe && !inEn) missingInEn.push(variable);
if (inEn && !inDe) missingInDe.push(variable);
}
if (missingInEn.length > 0) {
console.log(`\n⚠️ Variables in DE but missing in EN: ${missingInEn.join(', ')}`);
}
if (missingInDe.length > 0) {
console.log(`\n⚠️ Variables in EN but missing in DE: ${missingInDe.join(', ')}`);
}
// Overall quality score
const enScore = [
htmlEn.includes('style='),
htmlEn.includes('{{#if'),
htmlEn.includes('background-color'),
htmlEn.includes('<strong>'),
htmlEn.includes('margin:'),
htmlEn.includes('padding:')
].filter(Boolean).length;
const deScore = [
htmlDe.includes('style='),
htmlDe.includes('{{#if'),
htmlDe.includes('background-color'),
htmlDe.includes('<strong>'),
htmlDe.includes('margin:'),
htmlDe.includes('padding:')
].filter(Boolean).length;
console.log(`\nQuality score (out of 6) - EN: ${enScore}, DE: ${deScore}`);
console.log(enScore === deScore ? '✅ Templates have equal quality!' : '❌ Quality mismatch');
}
console.log('\n\nSummary:');
console.log('The English templates have been updated to match the German templates in:');
console.log('- HTML styling and structure');
console.log('- Conditional content blocks');
console.log('- Visual elements (buttons, alerts, icons)');
console.log('- Information completeness');
console.log('- Professional formatting');
} catch (error) {
console.error('Error:', error);
} finally {
await db.destroy();
}
}
verifyTemplateEquality();
+70 -41
View File
@@ -4,11 +4,17 @@ require('dotenv').config();
const { validateEnvironment } = require('./src/config/validateEnv');
validateEnvironment();
// Initialize logger early to capture startup logs
const logger = require('./src/utils/logger');
logger.info('Server starting up', {
nodeVersion: process.version,
environment: process.env.NODE_ENV || 'development',
timestamp: new Date().toISOString()
});
const express = require('express');
const helmet = require('helmet');
const cors = require('cors');
const rateLimit = require('express-rate-limit');
const jwt = require('jsonwebtoken');
const path = require('path');
const { initializeDatabase, db } = require('./src/database/db');
const { startFileWatcher } = require('./src/services/fileWatcher');
@@ -16,7 +22,7 @@ const { startExpirationChecker } = require('./src/services/expirationChecker');
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
const logger = require('./src/utils/logger');
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
// Import routes
const authRoutes = require('./src/routes/auth-enhanced');
@@ -93,41 +99,27 @@ const corsOptions = {
app.use(cors(corsOptions));
// Rate limiting with admin bypass
const limiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: process.env.NODE_ENV === 'development' ? 1000 : 100, // More lenient in development
skip: (req) => {
// Skip rate limiting for authenticated admin users
if (req.path.startsWith('/api/admin/') && req.headers.authorization) {
const token = req.headers.authorization.replace('Bearer ', '');
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET);
return decoded.type === 'admin';
} catch (err) {
return false;
}
}
// Also skip rate limiting for public settings endpoint in development
if (process.env.NODE_ENV === 'development' && req.path === '/api/public/settings') {
return true;
}
return false;
}
});
// Initialize rate limiters (they will be created dynamically)
let generalRateLimiter;
let authRateLimiter;
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5 // limit auth attempts
});
// Function to initialize rate limiters
async function initializeRateLimiters() {
generalRateLimiter = await createRateLimiter();
authRateLimiter = await createAuthRateLimiter();
// Apply rate limiting
app.use('/api/', generalRateLimiter);
app.use('/api/auth', authRateLimiter);
app.use('/api/gallery/:slug/verify', authRateLimiter);
app.use('/api/admin/auth/login', authRateLimiter);
}
// Apply rate limiting - admin routes check will skip for valid admin tokens
app.use('/api/', limiter);
app.use('/api/auth', authLimiter);
// Note: Rate limiters will be initialized after database connection
// 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 +138,39 @@ 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')));
// Debug endpoint to check IP detection (only in development)
if (process.env.NODE_ENV === 'development') {
app.get('/api/debug/ip', (req, res) => {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
res.json({
detectedIp: clientIp,
reqIp: req.ip,
headers: {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip'],
'x-forwarded-proto': req.headers['x-forwarded-proto'],
'x-forwarded-host': req.headers['x-forwarded-host']
},
trustProxy: app.get('trust proxy')
});
});
}
// Health check endpoint
app.get('/health', async (req, res) => {
@@ -200,9 +217,21 @@ async function startServer() {
// Initialize database
await initializeDatabase();
// Initialize auth security cleanup job
const { initializeCleanupJob } = require('./src/utils/authSecurity');
initializeCleanupJob();
// Initialize rate limiters after database is ready
await initializeRateLimiters();
logger.info('Rate limiters initialized with database configuration');
// Initialize auth security cleanup job
const { initializeCleanupJob } = require('./src/utils/authSecurity');
initializeCleanupJob();
// Initialize temp upload cleanup job
const { cleanupTempUploads } = require('./src/utils/cleanupTempUploads');
// Run cleanup on startup
cleanupTempUploads();
// Schedule periodic cleanup every hour
setInterval(cleanupTempUploads, 60 * 60 * 1000);
logger.info('Temp upload cleanup scheduled');
// Start file watcher
startFileWatcher();
+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);
});
});
});
+1 -1
View File
@@ -34,7 +34,7 @@ function validateEnvironment() {
if (name === 'JWT_SECRET' && value) {
// Check for the insecure default value
if (value === 'your-secret-key') {
errors.push(`CRITICAL: JWT_SECRET is set to the insecure default value. Please set a secure secret key.`);
errors.push('CRITICAL: JWT_SECRET is set to the insecure default value. Please set a secure secret key.');
}
// Check minimum length (should be at least 32 characters for security)
+3 -3
View File
@@ -59,9 +59,9 @@ async function initializeDatabase() {
)
`);
await db.raw(`INSERT INTO events_new SELECT * FROM events`);
await db.raw(`DROP TABLE events`);
await db.raw(`ALTER TABLE events_new RENAME TO events`);
await db.raw('INSERT INTO events_new SELECT * FROM events');
await db.raw('DROP TABLE events');
await db.raw('ALTER TABLE events_new RENAME TO events');
} catch (error) {
// If the migration fails, it might already have been applied
console.log('Color theme migration may have already been applied');
+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) {
+62 -2
View File
@@ -1,23 +1,83 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const logger = require('../utils/logger');
async function adminAuth(req, res, next) {
try {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.warn('Admin auth attempt without token', {
ip: clientIp,
path: req.path,
method: req.method,
userAgent: req.headers['user-agent']
});
return res.status(401).json({ error: 'No token provided' });
}
const decoded = jwt.verify(token, process.env.JWT_SECRET);
const admin = await db('admin_users').where({ id: decoded.id, is_active: true }).first();
let decoded;
try {
decoded = jwt.verify(token, process.env.JWT_SECRET);
} catch (jwtError) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.warn('JWT validation failed', {
ip: clientIp,
path: req.path,
method: req.method,
userAgent: req.headers['user-agent'],
error: jwtError.name,
message: jwtError.message,
timestamp: new Date().toISOString()
});
if (jwtError.name === 'TokenExpiredError') {
return res.status(401).json({ error: 'Token expired' });
}
return res.status(401).json({ error: 'Invalid token' });
}
const admin = await db('admin_users').where({ id: decoded.id, is_active: formatBoolean(true) }).first();
if (!admin) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.warn('Admin auth failed - user not found or inactive', {
ip: clientIp,
userId: decoded.id,
path: req.path,
method: req.method,
timestamp: new Date().toISOString()
});
return res.status(401).json({ error: 'Invalid token' });
}
req.admin = admin;
next();
} catch (error) {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
logger.error('Admin auth middleware error', {
ip: clientIp,
path: req.path,
error: error.message,
stack: error.stack,
timestamp: new Date().toISOString()
});
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' });
}
+111
View File
@@ -0,0 +1,111 @@
const fs = require('fs').promises;
const path = require('path');
const sharp = require('sharp');
const logger = require('../utils/logger');
/**
* Validate uploaded file is complete and not corrupted
*/
async function validateUploadedFile(filePath) {
try {
// Check file exists and has size
const stats = await fs.stat(filePath);
if (stats.size === 0) {
throw new Error('File is empty');
}
// For image files, verify they can be read by Sharp
const ext = path.extname(filePath).toLowerCase();
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
if (imageExtensions.includes(ext)) {
// Try to read metadata - this will fail if image is corrupted
let metadata;
try {
metadata = await sharp(filePath, {
failOnError: false, // Don't fail on recoverable errors
limitInputPixels: 268402689 // ~16k x 16k max
}).metadata();
} catch (metadataError) {
// If metadata reading fails, the file is likely incomplete
throw new Error(`Invalid image file: ${metadataError.message}`);
}
if (!metadata || !metadata.width || !metadata.height) {
throw new Error('Invalid image dimensions - file may be incomplete');
}
// Check for reasonable dimensions
if (metadata.width < 10 || metadata.height < 10) {
throw new Error('Image dimensions too small');
}
// Additional check: verify we can actually decode a small portion of the image
try {
await sharp(filePath, {
failOnError: false,
limitInputPixels: 268402689
})
.resize(10, 10) // Try to resize to very small size
.toBuffer();
} catch (decodeError) {
throw new Error(`Image decode failed - file may be corrupted: ${decodeError.message}`);
}
return true;
}
return true;
} catch (error) {
logger.error(`File validation failed for ${filePath}:`, error.message);
throw error;
}
}
/**
* Middleware to validate uploaded files after multer processing
*/
async function validateUploadedFiles(req, res, next) {
if (!req.files || req.files.length === 0) {
return next();
}
const validFiles = [];
const invalidFiles = [];
// Validate each file
for (const file of req.files) {
try {
await validateUploadedFile(file.path);
validFiles.push(file);
} catch (error) {
logger.warn(`Removing invalid upload ${file.originalname}: ${error.message}`);
invalidFiles.push({
filename: file.originalname,
error: error.message
});
// Delete the invalid file
try {
await fs.unlink(file.path);
} catch (unlinkErr) {
logger.error(`Failed to delete invalid file ${file.path}:`, unlinkErr.message);
}
}
}
// Update req.files to only include valid files
req.files = validFiles;
// Store invalid files info for response
if (invalidFiles.length > 0) {
req.invalidFiles = invalidFiles;
}
next();
}
module.exports = {
validateUploadedFile,
validateUploadedFiles
};
+34 -11
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);
}
}
@@ -248,7 +251,7 @@ router.post('/:id/restore', adminAuth, async (req, res) => {
} catch (statError) {
console.error(`Failed to stat file: ${actualFilePath}`);
console.error(`Entry name was: ${entry.entryName}`);
console.error(`Error:`, statError.message);
console.error('Error:', statError.message);
// Skip this file if we can't stat it
continue;
}
@@ -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();
+17 -15
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')
@@ -47,16 +48,16 @@ router.get('/stats', adminAuth, async (req, res) => {
.count('id as count')
.first();
// Get total downloads (last 30 days)
// Get total downloads (last 30 days) - include both single and bulk downloads
const totalDownloads = await db('access_logs')
.where('action', 'download')
.whereIn('action', ['download', 'download_all'])
.where('timestamp', '>=', thirtyDaysAgo.toISOString())
.count('id as count')
.first();
// Get archived events count
const archivedEvents = await db('events')
.where('is_archived', true)
.where('is_archived', formatBoolean(true))
.count('id as count')
.first();
@@ -72,7 +73,7 @@ router.get('/stats', adminAuth, async (req, res) => {
.first();
const previousDownloads = await db('access_logs')
.where('action', 'download')
.whereIn('action', ['download', 'download_all'])
.where('timestamp', '>=', sixtyDaysAgo.toISOString())
.where('timestamp', '<', thirtyDaysAgo.toISOString())
.count('id as count')
@@ -242,10 +243,10 @@ router.get('/analytics', adminAuth, async (req, res) => {
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
// Get downloads per day
// Get downloads per day - include both single and bulk downloads
const downloadsData = await db('access_logs')
.select(db.raw('DATE(timestamp) as date'), db.raw('COUNT(*) as count'))
.where('action', 'download')
.whereIn('action', ['download', 'download_all'])
.where('timestamp', '>=', startDateStr)
.groupByRaw('DATE(timestamp)');
@@ -271,14 +272,15 @@ router.get('/analytics', adminAuth, async (req, res) => {
if (dateObj) dateObj.uniqueVisitors = row.count;
});
// Get top galleries by views
// Get top galleries by views with additional metrics
const topGalleries = await db('access_logs')
.select('events.event_name', 'events.slug')
.select(db.raw('COUNT(*) as views'))
.select('events.id', 'events.event_name', 'events.slug')
.select(db.raw('COUNT(CASE WHEN action = \'view\' THEN 1 END) as views'))
.select(db.raw('COUNT(DISTINCT CASE WHEN action = \'view\' THEN ip_address END) as uniqueVisitors'))
.select(db.raw('COUNT(CASE WHEN action IN (\'download\', \'download_all\') THEN 1 END) as downloads'))
.join('events', 'access_logs.event_id', 'events.id')
.where('access_logs.action', 'view')
.where('access_logs.timestamp', '>=', startDateStr)
.groupBy('events.id')
.groupBy('events.id', 'events.event_name', 'events.slug')
.orderBy('views', 'desc')
.limit(5);
+9 -9
View File
@@ -210,15 +210,15 @@ router.get('/templates', adminAuth, async (req, res) => {
id: template.id,
template_key: template.template_key,
variables: (() => {
try {
if (!template.variables) return [];
if (typeof template.variables === 'object') return template.variables;
return JSON.parse(template.variables);
} catch (e) {
console.warn('Failed to parse variables for template:', template.template_key, e.message);
return [];
}
})(),
try {
if (!template.variables) return [];
if (typeof template.variables === 'object') return template.variables;
return JSON.parse(template.variables);
} catch (e) {
console.warn('Failed to parse variables for template:', template.template_key, e.message);
return [];
}
})(),
updated_at: template.updated_at
};
+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',
+172 -25
View File
@@ -8,9 +8,11 @@ const crypto = require('crypto');
const fs = require('fs').promises;
const path = require('path');
const { archiveEvent } = require('../services/archiveService');
const { queueEmail } = require('../services/emailProcessor');
const { escapeLikePattern } = require('../utils/sqlSecurity');
const { formatDate } = require('../utils/dateFormatter');
// formatDate import removed - dates are formatted by email processor
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
const { formatBoolean } = require('../utils/dbCompat');
// Create new event
router.post('/', adminAuth, [
@@ -65,7 +67,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;
@@ -82,7 +89,14 @@ router.post('/', adminAuth, [
const password_hash = await bcrypt.hash(password, getBcryptRounds());
// Calculate expiration date (days after event date)
const expires_at = new Date(event_date);
// Parse YYYY-MM-DD format as local date to avoid timezone issues
let expires_at;
if (event_date.match(/^\d{4}-\d{2}-\d{2}$/)) {
const [year, month, day] = event_date.split('-').map(num => parseInt(num, 10));
expires_at = new Date(year, month - 1, day);
} else {
expires_at = new Date(event_date);
}
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
// Create folder structure
@@ -92,7 +106,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 +122,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',
@@ -118,8 +135,7 @@ router.post('/', adminAuth, [
);
// Queue creation email
// Determine language based on email domain
const emailLang = host_email.endsWith('.de') ? 'de' : 'en';
// Language detection is handled by email processor
await db('email_queue').insert({
event_id: eventId,
@@ -128,12 +144,14 @@ router.post('/', adminAuth, [
email_data: JSON.stringify({
host_name: host_name,
event_name,
event_date: await formatDate(event_date, emailLang),
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareLink,
gallery_password: password,
expiry_date: await formatDate(expires_at, emailLang),
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
})
}),
status: 'pending',
created_at: new Date()
// scheduled_at will use default value
});
@@ -178,17 +196,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 +400,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 +459,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
});
}
}
});
@@ -474,16 +547,15 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
// Queue email notification if requested
if (sendEmail) {
const { queueEmail } = require('../services/emailProcessor');
// For password reset, we'll need to create a template or use a different approach
// For now, let's use the gallery_created template with updated password
await queueEmail(id, event.host_email, 'gallery_created', {
host_name: event.host_email.split('@')[0],
event_name: event.event_name,
event_date: new Date(event.event_date).toLocaleDateString(),
event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: event.share_link,
gallery_password: newPassword,
expiry_date: new Date(event.expires_at).toLocaleDateString()
expiry_date: event.expires_at // Pass raw date - will be formatted by email processor
});
}
@@ -498,6 +570,81 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
}
});
// Resend creation email
router.post('/:id/resend-email', adminAuth, async (req, res) => {
try {
const { id } = req.params;
// Get event details
const event = await db('events')
.where('id', id)
.first();
if (!event) {
return res.status(404).json({ error: 'Event not found' });
}
// The email processor will determine the language based on:
// 1. Event language setting
// 2. App settings general_default_language
// 3. Email config default language
// 4. Domain-based detection
// So we don't need to determine it here
// For resending creation email, we need the actual password
// First, try to get it from the request body if provided
let galleryPassword = req.body.password;
// If no password provided, we can't decrypt the existing one
// So we'll show a security message
if (!galleryPassword) {
// We'll let the email processor determine the language for the security message
galleryPassword = '{{password_security_message}}';
}
// Dates will be formatted by the email processor based on recipient language
// Queue the email
await queueEmail(id, event.host_email, 'gallery_created', {
host_name: event.host_name || event.host_email.split('@')[0],
event_name: event.event_name,
event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: event.share_link,
gallery_password: galleryPassword,
expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor
welcome_message: event.welcome_message || '',
eventId: id,
isResend: true // Flag to indicate this is a resend
});
// Log the activity using the proper schema
try {
await logActivity('email_resent', {
email_type: 'gallery_created',
recipient: event.host_email,
ip_address: req.ip || '0.0.0.0',
user_agent: req.get('user-agent') || 'Unknown'
}, id, {
type: 'admin',
id: req.admin.id,
name: req.admin.username
});
} catch (logError) {
console.error('Warning: Failed to log activity:', logError);
// Don't fail the request if activity logging fails
}
res.json({
success: true,
message: 'Creation email has been queued for sending'
});
} catch (error) {
console.error('Error resending creation email:', error);
console.error('Stack trace:', error.stack);
res.status(500).json({ error: 'Failed to resend creation email' });
}
});
// Archive event
router.post('/:id/archive', adminAuth, async (req, res) => {
try {
@@ -549,7 +696,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' });
+325 -128
View File
@@ -4,55 +4,41 @@ const path = require('path');
const fs = require('fs').promises;
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { generateThumbnail } = require('../services/imageProcessor');
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation');
const router = express.Router();
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
// Configure multer for file uploads
// IMPORTANT: Using synchronous functions to prevent file corruption
const storage = multer.diskStorage({
destination: async (req, file, cb) => {
destination: (req, file, cb) => {
console.log('Multer destination called for file:', file.originalname);
const { eventId } = req.params;
try {
// Get event details
const event = await db('events').where({ id: eventId }).first();
if (!event) {
console.error('Event not found in multer destination:', eventId);
return cb(new Error('Event not found'));
}
// Store event in request for use in filename generation
req.eventData = event;
// Create destination path - now just event folder, no type subfolder
const destPath = path.join(getStoragePath(), 'events/active', event.slug);
console.log('Destination path:', destPath);
// Ensure directory exists
await fs.mkdir(destPath, { recursive: true });
cb(null, destPath);
} catch (error) {
console.error('Error in multer destination:', error);
cb(error);
}
// We'll validate the event exists in the route handler
// For now, just create a temp destination
const tempPath = path.join(getStoragePath(), 'temp', `upload_${Date.now()}_${Math.random().toString(36).substring(7)}`);
// Create directory synchronously
require('fs').mkdirSync(tempPath, { recursive: true });
console.log('Temp destination path:', tempPath);
// Store temp path for cleanup
req.tempUploadPath = tempPath;
cb(null, tempPath);
},
filename: async (req, file, cb) => {
filename: (req, file, cb) => {
console.log('Multer filename called for file:', file.originalname);
try {
// Use temporary filename for now, will rename after getting category info
const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`;
console.log('Temp filename:', tempName);
cb(null, tempName);
} catch (error) {
console.error('Error in multer filename:', error);
cb(error);
}
// Use a simple temporary filename
const tempName = `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${path.extname(file.originalname)}`;
console.log('Temp filename:', tempName);
cb(null, tempName);
}
});
@@ -61,7 +47,13 @@ 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
// Add part size limits to prevent incomplete uploads
parts: 10000, // Maximum number of parts (fields + files)
headerPairs: 2000 // Maximum number of header key-value pairs
},
fileFilter: (req, file, cb) => {
// Accept images only with proper validation
@@ -72,7 +64,9 @@ const upload = multer({
} else {
cb(new Error('Only JPEG, PNG and WebP images are allowed'));
}
}
},
// Add abort on limit to stop processing when limits are exceeded
abortOnLimit: true
});
const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
@@ -84,14 +78,38 @@ const validateUploadContent = createFileUploadValidator({
validateContent: true
});
// Request timeout middleware for uploads
const uploadTimeout = (timeout = 300000) => { // 5 minutes default
return (req, res, next) => {
// Set timeout for the request
req.setTimeout(timeout, () => {
console.error('Upload request timed out');
if (!res.headersSent) {
res.status(408).json({ error: 'Upload request timed out' });
}
});
// Set response timeout as well
res.setTimeout(timeout, () => {
console.error('Upload response timed out');
});
next();
};
};
// Upload photos for an event
router.post('/:eventId/upload', adminAuth, (req, res, next) => {
upload.array('photos', 20)(req, res, (err) => {
// Increased limit to 500 files, but recommend chunked uploads for better performance
router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), (req, res, next) => { // 10 minute timeout
upload.array('photos', 500)(req, res, (err) => {
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}` });
}
@@ -99,7 +117,7 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
}
next();
});
}, validateUploadContent, async (req, res) => {
}, validateUploadContent, validateUploadedFiles, async (req, res) => {
try {
const { eventId } = req.params;
const { category_id } = req.body;
@@ -114,12 +132,28 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
const event = await db('events').where({ id: eventId }).first();
if (!event) {
console.error('Event not found:', eventId);
// Clean up temp files
if (req.tempUploadPath) {
try {
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
} catch (e) {
console.error('Failed to clean up temp path:', e);
}
}
return res.status(404).json({ error: 'Event not found' });
}
if (!req.files || req.files.length === 0) {
console.error('No files in request. req.files:', req.files);
console.error('Request body keys:', Object.keys(req.body));
// Clean up temp files
if (req.tempUploadPath) {
try {
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
} catch (e) {
console.error('Failed to clean up temp path:', e);
}
}
return res.status(400).json({ error: 'No files uploaded' });
}
@@ -131,95 +165,213 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
if (parsedCategoryId) {
category = await db('photo_categories').where({ id: parsedCategoryId }).first();
if (!category) {
// Clean up temp files
if (req.tempUploadPath) {
try {
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
} catch (e) {
console.error('Failed to clean up temp path:', e);
}
}
return res.status(400).json({ error: 'Invalid category' });
}
}
const uploadedPhotos = [];
// Create final destination directory
const finalDestPath = path.join(getStoragePath(), 'events/active', event.slug);
await fs.mkdir(finalDestPath, { recursive: true });
// Process each uploaded file
for (const file of req.files) {
let trx;
const uploadedPhotos = [];
const errors = [];
// Process files in batches to optimize database operations
const BATCH_SIZE = 25; // Increased batch size for better performance with large uploads
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 = (parseInt(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 = [];
const fileRenameOperations = []; // Store rename operations to do after commit
// Rename the file
const oldPath = file.path;
const newPath = path.join(path.dirname(oldPath), newFilename);
await fs.rename(oldPath, newPath);
// First pass: prepare data and move files from temp to final location
for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) {
const file = batch[fileIndex];
const counter = batchCounter + fileIndex;
const tempPath = file.path; // Original temp path
try {
// Verify file is complete before processing
const tempStats = await fs.stat(tempPath);
if (tempStats.size === 0) {
throw new Error('File is empty - upload may have been interrupted');
}
// Generate new filename
const extension = path.extname(file.originalname);
const newFilename = generatePhotoFilename(
event.event_name,
category ? category.name : 'uncategorized',
counter,
extension
);
// Calculate final path
const finalPath = path.join(finalDestPath, newFilename);
const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), finalPath);
// Prepare photo data for batch insert
const photoData = {
event_id: parseInt(eventId),
filename: newFilename,
path: relativePath,
thumbnail_path: null, // Will generate after successful commit
category_id: parsedCategoryId ? parseInt(parsedCategoryId) : null,
type: 'individual',
size_bytes: tempStats.size // Use actual file size from stat
};
batchPhotos.push(photoData);
// Store move operation for later
fileRenameOperations.push({
tempPath: tempPath,
finalPath: finalPath,
filename: newFilename,
photoData: photoData
});
} catch (error) {
console.error(`Error preparing file ${file.originalname}:`, error);
errors.push({ filename: file.originalname, error: error.message });
}
}
// 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; // 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
await trx.commit();
uploadedPhotos.push({
id: photoId,
filename: file.filename,
size: file.size,
category_id: parsedCategoryId || null
});
// Insert all photos in this batch
if (batchPhotos.length > 0) {
console.log(`Inserting batch of ${batchPhotos.length} photos with category_id: ${parsedCategoryId}`);
const insertedIds = await trx('photos').insert(batchPhotos).returning('id');
// Update category counter if needed
if (category && parsedCategoryId) {
const newCounter = batchCounter + batchPhotos.length - 1;
await trx('photo_categories')
.where({ id: parsedCategoryId })
.update({ photo_counter: newCounter });
console.log(`Updated category ${parsedCategoryId} counter to ${newCounter}`);
}
// Commit the transaction first
await trx.commit();
console.log(`Successfully committed batch of ${batchPhotos.length} photos`);
// Now move files from temp to final location after successful commit
for (let idx = 0; idx < fileRenameOperations.length; idx++) {
const operation = fileRenameOperations[idx];
try {
// Move the file from temp to final location
await fs.rename(operation.tempPath, operation.finalPath);
console.log(`Moved file from ${operation.tempPath} to ${operation.finalPath}`);
// Verify the file was moved successfully
const finalStats = await fs.stat(operation.finalPath);
if (finalStats.size !== operation.photoData.size_bytes) {
throw new Error(`File size mismatch after move: expected ${operation.photoData.size_bytes}, got ${finalStats.size}`);
}
// Generate thumbnail with final path
let thumbnailPath = null;
try {
thumbnailPath = await generateThumbnail(operation.finalPath);
// Update the database with thumbnail path
if (thumbnailPath && insertedIds[idx]) {
const photoId = insertedIds[idx]?.id || insertedIds[idx];
await db('photos')
.where({ id: photoId })
.update({ thumbnail_path: thumbnailPath });
}
} catch (thumbError) {
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
}
// Add to successful uploads
uploadedPhotos.push({
id: insertedIds[idx]?.id || insertedIds[idx],
filename: operation.filename,
size: operation.photoData.size_bytes,
category_id: operation.photoData.category_id
});
} catch (moveError) {
console.error(`Failed to move file ${operation.tempPath} to ${operation.finalPath}:`, moveError);
errors.push({
filename: operation.filename,
error: `File move failed: ${moveError.message}`
});
// Try to clean up the database entry if file move failed
if (insertedIds[idx]) {
const photoId = insertedIds[idx]?.id || insertedIds[idx];
try {
await db('photos').where({ id: photoId }).delete();
console.log(`Cleaned up database entry for failed photo ${photoId}`);
} catch (cleanupError) {
console.error(`Failed to clean up database entry:`, cleanupError);
}
}
}
}
} else {
// No photos to insert, just rollback
await trx.rollback();
}
} 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);
console.error('Stack trace:', error.stack);
// Rollback if not already committed
if (!trx.isCompleted()) {
await trx.rollback();
}
// Add all files in this batch to errors
for (const file of batch) {
errors.push({
filename: file.originalname,
error: `Batch processing failed: ${error.message}`
});
}
}
}
// Clean up temp upload directory
if (req.tempUploadPath) {
try {
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
console.log(`Cleaned up temp upload directory: ${req.tempUploadPath}`);
} catch (e) {
console.error('Failed to clean up temp upload directory:', e);
}
}
@@ -230,12 +382,39 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({
// Include any files that were invalid from the validation middleware
const totalInvalidFiles = (req.invalidFiles || []).concat(errors);
// Prepare response
const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0);
const response = {
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
photos: uploadedPhotos
});
photos: uploadedPhotos,
totalFiles: totalAttempted,
successCount: uploadedPhotos.length,
failureCount: totalInvalidFiles.length
};
// Include error details if any files failed
if (totalInvalidFiles.length > 0) {
response.errors = totalInvalidFiles;
response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`;
}
res.json(response);
} catch (error) {
console.error('Error uploading photos:', error);
// Clean up temp upload directory on error
if (req.tempUploadPath) {
try {
await fs.rm(req.tempUploadPath, { recursive: true, force: true });
console.log(`Cleaned up temp upload directory after error: ${req.tempUploadPath}`);
} catch (e) {
console.error('Failed to clean up temp upload directory:', e);
}
}
res.status(500).json({ error: 'Failed to upload photos' });
}
});
@@ -501,8 +680,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,
@@ -562,26 +741,24 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
.where({ id: photoId, event_id: eventId })
.first();
if (!photo || !photo.thumbnail_path) {
console.error(`Thumbnail not found for photo ${photoId}, event ${eventId}`);
return res.status(404).json({ error: 'Thumbnail not found' });
if (!photo) {
console.error(`Photo not found: ${photoId}, event ${eventId}`);
return res.status(404).json({ error: 'Photo not found' });
}
// Ensure thumbnail exists and is valid, regenerate if needed
const thumbnailPath = await ensureThumbnail(photo);
if (!thumbnailPath) {
console.error(`Failed to generate thumbnail for photo ${photoId}`);
return res.status(404).json({ error: 'Thumbnail generation failed' });
}
const storagePath = getStoragePath();
const filePath = path.join(storagePath, photo.thumbnail_path);
console.log(`Attempting to serve thumbnail: ${filePath}`);
// Check if file exists
try {
await fs.access(filePath);
} catch (error) {
console.error(`Thumbnail file not found: ${filePath}`, error);
return res.status(404).json({ error: 'Thumbnail file not found' });
}
const filePath = path.join(storagePath, thumbnailPath);
// Set appropriate headers
res.setHeader('Content-Type', `image/${path.extname(photo.thumbnail_path).slice(1)}`);
res.setHeader('Content-Type', 'image/jpeg'); // Thumbnails are always JPEG
res.setHeader('Cache-Control', 'private, max-age=3600');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
@@ -595,4 +772,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;
+113 -6
View File
@@ -4,8 +4,10 @@ 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 { clearSettingsCache } = require('../services/rateLimitService');
const router = express.Router();
// Configure multer for logo uploads
@@ -494,6 +496,43 @@ router.put('/security', adminAuth, async (req, res) => {
}
});
// Update analytics settings
router.put('/analytics', adminAuth, async (req, res) => {
try {
const settings = req.body;
// Update or insert each setting
for (const [key, value] of Object.entries(settings)) {
await db('app_settings')
.insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'analytics',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: JSON.stringify(value),
updated_at: new Date()
});
}
// Log activity
await db('activity_logs').insert({
activity_type: 'analytics_settings_updated',
actor_type: 'admin',
actor_id: req.admin.id,
actor_name: req.admin.username,
metadata: JSON.stringify({ settings_count: Object.keys(settings).length })
});
res.json({ message: 'Analytics settings updated successfully' });
} catch (error) {
console.error('Analytics settings update error:', error);
res.status(500).json({ error: 'Failed to update analytics settings' });
}
});
// Get storage info
router.get('/storage/info', adminAuth, async (req, res) => {
try {
@@ -513,17 +552,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);
}
}
}
@@ -577,4 +620,68 @@ router.post('/favicon', adminAuth, faviconUpload.single('favicon'), async (req,
}
});
// Update rate limit settings
router.put('/security/rate-limit', adminAuth, [
body('rate_limit_enabled').isBoolean().withMessage('Enabled must be a boolean'),
body('rate_limit_window_minutes').isInt({ min: 1, max: 60 }).withMessage('Window must be between 1 and 60 minutes'),
body('rate_limit_max_requests').isInt({ min: 10, max: 10000 }).withMessage('Max requests must be between 10 and 10000'),
body('rate_limit_auth_max_requests').isInt({ min: 1, max: 100 }).withMessage('Auth max requests must be between 1 and 100'),
body('rate_limit_skip_authenticated').isBoolean().withMessage('Skip authenticated must be a boolean'),
body('rate_limit_public_endpoints_only').isBoolean().withMessage('Public endpoints only must be a boolean')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const {
rate_limit_enabled,
rate_limit_window_minutes,
rate_limit_max_requests,
rate_limit_auth_max_requests,
rate_limit_skip_authenticated,
rate_limit_public_endpoints_only
} = req.body;
// Update each setting
const settings = [
{ key: 'rate_limit_enabled', value: rate_limit_enabled },
{ key: 'rate_limit_window_minutes', value: rate_limit_window_minutes },
{ key: 'rate_limit_max_requests', value: rate_limit_max_requests },
{ key: 'rate_limit_auth_max_requests', value: rate_limit_auth_max_requests },
{ key: 'rate_limit_skip_authenticated', value: rate_limit_skip_authenticated },
{ key: 'rate_limit_public_endpoints_only', value: rate_limit_public_endpoints_only }
];
for (const { key, value } of settings) {
await db('app_settings')
.where('setting_key', key)
.update({
setting_value: JSON.stringify(value),
updated_at: new Date()
});
}
// Clear the rate limit settings cache to apply changes immediately
clearSettingsCache();
// Log activity
await logActivity('settings_updated',
{
category: 'security',
subcategory: 'rate_limit',
changes: settings.length
},
null,
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
res.json({ message: 'Rate limit settings updated successfully' });
} catch (error) {
console.error('Rate limit settings update error:', error);
res.status(500).json({ error: 'Failed to update rate limit settings' });
}
});
module.exports = router;
+42
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
@@ -69,12 +70,46 @@ router.get('/status', adminAuth, async (req, res) => {
// Email queue status
const [pendingEmails] = await db('email_queue').where('status', 'pending').count('* as count');
const [processableEmails] = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '<', 3)
.count('* as count');
const [sentEmails] = await db('email_queue').where('status', 'sent').count('* as count');
const [failedEmails] = await db('email_queue').where('status', 'failed').count('* as count');
const [stuckEmails] = await db('email_queue')
.where('status', 'pending')
.where('retry_count', '>=', 3)
.count('* as count');
// 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,8 +140,15 @@ router.get('/status', adminAuth, async (req, res) => {
activityLogs: activityCount.count
}
},
storage: {
totalUsed: totalStorage,
photoStorage: parseInt(totalPhotoStorage) || 0,
archiveStorage: archiveStorage
},
emailQueue: {
pending: pendingEmails.count,
processable: processableEmails.count,
stuck: stuckEmails.count,
sent: sentEmails.count,
failed: failedEmails.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) {
+12 -8
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,17 +77,20 @@ 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');
await queueEmail(eventId, host_email, 'gallery_created', {
host_name: host_email.split('@')[0], // Extract name from email
event_name,
event_date: new Date(event_date).toLocaleDateString(),
event_date: event_date, // Pass raw date - will be formatted by email processor
gallery_link: shareLink,
gallery_password: password,
expiry_date: expires_at.toLocaleDateString(),
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
welcome_message: welcome_message || ''
});
@@ -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) {
+12 -3
View File
@@ -5,9 +5,13 @@ const router = express.Router();
// Get public settings (branding and theme)
router.get('/', async (req, res) => {
try {
// Fetch branding, theme, general, and select security settings
// Fetch branding, theme, general, and security settings
// Note: We include analytics in the query but it might not exist yet
const settings = await db('app_settings')
.whereIn('setting_type', ['branding', 'theme', 'general', 'security'])
.where(function() {
this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics'])
.orWhere('setting_key', 'like', 'analytics_%');
})
.select('setting_key', 'setting_value');
// Convert to object format
@@ -41,7 +45,12 @@ router.get('/', async (req, res) => {
enable_analytics: settingsObject.general_enable_analytics !== false,
enable_recaptcha: settingsObject.security_enable_recaptcha === true || settingsObject.security_enable_recaptcha === 'true',
recaptcha_site_key: settingsObject.security_recaptcha_site_key || null,
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true'
maintenance_mode: settingsObject.general_maintenance_mode === true || settingsObject.general_maintenance_mode === 'true',
// Umami analytics configuration (only if enabled)
umami_enabled: settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true',
umami_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_url || null) : null,
umami_website_id: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_website_id || null) : null,
umami_share_url: (settingsObject.analytics_umami_enabled === true || settingsObject.analytics_umami_enabled === 'true') ? (settingsObject.analytics_umami_share_url || null) : null
};
res.json(publicSettings);
+190 -53
View File
@@ -1,11 +1,20 @@
const nodemailer = require('nodemailer');
const Handlebars = require('handlebars');
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 +23,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,29 +47,72 @@ 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;
}
}
// Get the appropriate language for a recipient
async function getRecipientLanguage(email) {
// For now, check if the email domain ends with .de
// In the future, this could check user preferences
if (email && email.endsWith('.de')) {
return 'de';
async function getRecipientLanguage(email, eventId = null) {
// First priority: Check event language setting if eventId is provided
if (eventId) {
try {
const event = await db('events').where('id', eventId).first();
if (event && event.language) {
return event.language;
}
} catch (error) {
logger.error('Error fetching event language:', error);
}
}
// Check if there's a saved preference for this email
// This could be expanded to check user preferences in the database
// Second priority: Check app_settings for general default language
try {
const langSetting = await db('app_settings')
.where('setting_key', 'general_default_language')
.first();
if (langSetting && langSetting.setting_value) {
return langSetting.setting_value;
}
} catch (error) {
logger.error('Error fetching app settings language:', error);
}
// Third priority: Check email configs for default language
try {
const emailConfig = await db('email_configs').first();
if (emailConfig && emailConfig.default_language) {
return emailConfig.default_language;
}
} catch (error) {
logger.error('Error fetching email config language:', error);
}
// Fourth priority: Check if the email domain suggests German
if (email) {
const germanDomains = ['.de', '.at', '.ch', '.li'];
const domain = email.toLowerCase();
if (germanDomains.some(d => domain.endsWith(d))) {
return 'de';
}
}
return 'en'; // Default to English
}
// Process email template with variables
async function processTemplate(template, variables, language = 'en') {
// Import date formatter and text formatters
const { formatDate } = require('../utils/dateFormatter');
const { formatWelcomeMessage } = require('../utils/formatters');
// Get the appropriate language fields
const subjectField = language === 'de' ? 'subject_de' : 'subject_en';
const htmlField = language === 'de' ? 'body_html_de' : 'body_html_en';
@@ -60,6 +122,32 @@ async function processTemplate(template, variables, language = 'en') {
let subject = template[subjectField] || template.subject || '';
let htmlBody = template[htmlField] || template.body_html || '';
let textBody = template[textField] || template.body_text || '';
// Process variables before template compilation
const processedVariables = { ...variables };
// Handle password security message
if (processedVariables.gallery_password === '{{password_security_message}}') {
processedVariables.gallery_password = language === 'de'
? '(Aus Sicherheitsgründen nicht angezeigt)'
: '(Not shown for security reasons)';
}
// Format dates if they exist
if (processedVariables.event_date) {
processedVariables.event_date = await formatDate(processedVariables.event_date, language);
}
if (processedVariables.expiry_date) {
processedVariables.expiry_date = await formatDate(processedVariables.expiry_date, language);
}
if (processedVariables.archive_date) {
processedVariables.archive_date = await formatDate(processedVariables.archive_date, language);
}
// Format welcome message for HTML display (preserve line breaks)
if (processedVariables.welcome_message) {
processedVariables.welcome_message = formatWelcomeMessage(processedVariables.welcome_message);
}
// Get branding settings for logo
let logoUrl = '';
@@ -93,27 +181,15 @@ async function processTemplate(template, variables, language = 'en') {
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3005';
const logoFullUrl = logoUrl ? `${apiUrl}${logoUrl}` : `${frontendUrl}/picpeak-logo-transparent.png`;
// Process welcome message section if present
let welcomeMessageSection = '';
if (variables.welcome_message && variables.welcome_message.trim() !== '') {
const welcomeTitle = language === 'de' ? 'Persönliche Nachricht:' : 'Personal Message:';
welcomeMessageSection = `
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">${welcomeTitle}</p>
<p style="margin: 0; color: #4b5563;">${variables.welcome_message}</p>
</div>`;
}
// Replace variables
Object.entries(variables).forEach(([key, value]) => {
const regex = new RegExp(`{{${key}}}`, 'g');
subject = subject.replace(regex, value || '');
htmlBody = htmlBody.replace(regex, value || '');
textBody = textBody.replace(regex, value || '');
});
// Replace welcome message section placeholder
htmlBody = htmlBody.replace(/{{welcome_message_section}}/g, welcomeMessageSection);
// Compile templates with Handlebars
const subjectTemplate = Handlebars.compile(subject);
const htmlTemplate = Handlebars.compile(htmlBody);
const textTemplate = Handlebars.compile(textBody);
// Process templates with processedVariables (includes formatted dates and security messages)
subject = subjectTemplate(processedVariables);
htmlBody = htmlTemplate(processedVariables);
textBody = textTemplate(processedVariables);
// Wrap HTML body in styled template
const styledHtmlBody = `
@@ -256,11 +332,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
@@ -278,8 +353,8 @@ async function sendTemplateEmail(to, templateKey, variables) {
throw new Error('Email configuration not found');
}
// Determine recipient language
const language = await getRecipientLanguage(to);
// Determine recipient language (pass eventId if available in variables)
const language = await getRecipientLanguage(to, variables.eventId || null);
// Process template with variables
const { subject, htmlBody, textBody } = await processTemplate(template, variables, language);
@@ -303,14 +378,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 +412,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 +433,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);
}
@@ -356,6 +463,8 @@ async function processEmailQueue() {
// Queue an email for sending
async function queueEmail(eventId, recipientEmail, emailType, emailData) {
try {
// Add eventId to emailData for language detection
emailData.eventId = eventId;
await db('email_queue').insert({
event_id: eventId,
recipient_email: recipientEmail,
@@ -373,17 +482,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 +544,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', {
+9 -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');
@@ -50,8 +51,15 @@ async function processNewPhoto(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
// Skip temporary upload files
const filename = path.basename(filePath);
if (filename.startsWith('temp_')) {
logger.debug(`Skipping temporary upload file: ${filename}`);
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
+115 -11
View File
@@ -1,12 +1,17 @@
const sharp = require('sharp');
const path = require('path');
const fs = require('fs').promises;
const logger = require('../utils/logger');
// 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');
async function generateThumbnail(imagePath) {
async function generateThumbnail(imagePath, options = {}) {
const filename = path.basename(imagePath);
const thumbnailFilename = `thumb_${filename}`;
const thumbnailDir = getThumbnailPath();
@@ -15,16 +20,115 @@ 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'
})
.jpeg({ quality: 80 })
.toFile(thumbnailPath);
// Check if we need to regenerate (for broken thumbnails)
if (options.regenerate) {
try {
await fs.unlink(thumbnailPath);
logger.info(`Deleted broken thumbnail: ${thumbnailPath}`);
} catch (err) {
// File might not exist, that's okay
}
}
return path.relative(getStoragePath(), thumbnailPath);
try {
// First, verify the source image is complete and valid
const metadata = await sharp(imagePath).metadata();
if (!metadata.width || !metadata.height) {
throw new Error('Invalid image metadata - file may be incomplete');
}
// Generate thumbnail with memory-efficient settings and error handling
await sharp(imagePath, {
limitInputPixels: 268402689, // ~16k x 16k max
sequentialRead: true, // More memory efficient for large images
failOnError: false // Don't fail on minor issues
})
.resize(THUMBNAIL_WIDTH, null, {
withoutEnlargement: true,
fit: 'inside'
})
.jpeg({
quality: 80,
progressive: true, // Progressive JPEG for better loading
mozjpeg: true // Better compression
})
.toFile(thumbnailPath);
// Verify the thumbnail was created successfully
const stats = await fs.stat(thumbnailPath);
if (stats.size === 0) {
throw new Error('Generated thumbnail is empty');
}
return path.relative(getStoragePath(), thumbnailPath);
} catch (error) {
logger.error(`Failed to generate thumbnail for ${filename}:`, error.message);
// Clean up any partially created file
try {
await fs.unlink(thumbnailPath);
} catch (unlinkErr) {
// Ignore unlink errors
}
// Return null if thumbnail generation fails, don't fail the whole upload
return null;
}
}
module.exports = { generateThumbnail };
/**
* Check if a thumbnail exists and is valid
*/
async function isThumbnailValid(thumbnailPath) {
try {
const fullPath = path.join(getStoragePath(), thumbnailPath);
const stats = await fs.stat(fullPath);
// Check if file exists and has content
if (stats.size === 0) {
return false;
}
// Try to read metadata to ensure it's a valid image
await sharp(fullPath).metadata();
return true;
} catch (error) {
return false;
}
}
/**
* Regenerate thumbnail if it's broken or missing
*/
async function ensureThumbnail(photo) {
const storagePath = getStoragePath();
const originalPath = path.join(storagePath, 'events/active', photo.path);
// Check if thumbnail exists and is valid
if (photo.thumbnail_path) {
const isValid = await isThumbnailValid(photo.thumbnail_path);
if (isValid) {
return photo.thumbnail_path;
}
logger.warn(`Invalid thumbnail detected for photo ${photo.id}, regenerating...`);
}
// Generate new thumbnail
const newThumbnailPath = await generateThumbnail(originalPath, { regenerate: true });
if (newThumbnailPath) {
// Update database with new thumbnail path
const { db } = require('../database/db');
await db('photos')
.where({ id: photo.id })
.update({ thumbnail_path: newThumbnailPath });
logger.info(`Regenerated thumbnail for photo ${photo.id}`);
return newThumbnailPath;
}
return null;
}
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail };
+283
View File
@@ -0,0 +1,283 @@
const rateLimit = require('express-rate-limit');
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const logger = require('../utils/logger');
// Cache for rate limit settings
let settingsCache = null;
let cacheExpiry = 0;
const CACHE_DURATION = 60000; // 1 minute cache
/**
* Get rate limit settings from database with caching
*/
async function getRateLimitSettings() {
try {
// Check cache
if (settingsCache && Date.now() < cacheExpiry) {
return settingsCache;
}
// Fetch from database
const settings = await db('app_settings')
.whereIn('setting_key', [
'rate_limit_enabled',
'rate_limit_window_minutes',
'rate_limit_max_requests',
'rate_limit_auth_max_requests',
'rate_limit_skip_authenticated',
'rate_limit_public_endpoints_only'
]);
// Parse settings into object
const config = {
enabled: true,
windowMinutes: 15,
maxRequests: 100,
authMaxRequests: 5,
skipAuthenticated: true,
publicEndpointsOnly: false
};
settings.forEach(setting => {
const value = JSON.parse(setting.setting_value);
switch (setting.setting_key) {
case 'rate_limit_enabled':
config.enabled = value;
break;
case 'rate_limit_window_minutes':
config.windowMinutes = value;
break;
case 'rate_limit_max_requests':
config.maxRequests = value;
break;
case 'rate_limit_auth_max_requests':
config.authMaxRequests = value;
break;
case 'rate_limit_skip_authenticated':
config.skipAuthenticated = value;
break;
case 'rate_limit_public_endpoints_only':
config.publicEndpointsOnly = value;
break;
}
});
// Update cache
settingsCache = config;
cacheExpiry = Date.now() + CACHE_DURATION;
return config;
} catch (error) {
logger.error('Failed to fetch rate limit settings:', error);
// Return defaults on error
return {
enabled: true,
windowMinutes: 15,
maxRequests: 100,
authMaxRequests: 5,
skipAuthenticated: true,
publicEndpointsOnly: false
};
}
}
/**
* Clear settings cache (call when settings are updated)
*/
function clearSettingsCache() {
settingsCache = null;
cacheExpiry = 0;
}
/**
* Check if request has valid authentication
*/
function isAuthenticated(req) {
try {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return false;
}
const token = authHeader.substring(7);
const decoded = jwt.verify(token, process.env.JWT_SECRET);
// Check if token is valid
if (!decoded || typeof decoded !== 'object') {
return false;
}
// Valid token found - check type
req.tokenType = decoded.type; // 'admin' or 'gallery'
req.tokenPayload = decoded;
return true;
} catch (error) {
return false;
}
}
/**
* Determine if rate limiting should be applied to this request
*/
function shouldSkipRateLimit(req, config) {
// If rate limiting is disabled globally
if (!config.enabled) {
return true;
}
// Never skip rate limiting for auth endpoints
const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/);
if (isAuthEndpoint) {
return false;
}
// Check if we should skip authenticated requests
if (config.skipAuthenticated && isAuthenticated(req)) {
return true;
}
// Check if we only rate limit public endpoints
if (config.publicEndpointsOnly) {
const isPublicEndpoint = req.path.startsWith('/api/public/') ||
req.path.startsWith('/api/gallery/') ||
isAuthEndpoint;
return !isPublicEndpoint;
}
return false;
}
/**
* Create dynamic rate limiter
*/
async function createRateLimiter() {
const config = await getRateLimitSettings();
return rateLimit({
windowMs: config.windowMinutes * 60 * 1000,
max: async (req) => {
// Refresh config for each request
const currentConfig = await getRateLimitSettings();
// Different limits for auth endpoints
const isAuthEndpoint = req.path.match(/\/(auth|login|gallery\/[^/]+\/verify)$/);
return isAuthEndpoint ? currentConfig.authMaxRequests : currentConfig.maxRequests;
},
keyGenerator: (req) => {
// Use correct client IP when behind proxy
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
},
skip: async (req) => {
const currentConfig = await getRateLimitSettings();
return shouldSkipRateLimit(req, currentConfig);
},
handler: (req, res) => {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
// Enhanced logging for production analysis
logger.warn('Rate limit exceeded', {
ip: clientIp,
path: req.path,
method: req.method,
authenticated: isAuthenticated(req),
tokenType: req.tokenType,
userAgent: req.headers['user-agent'],
referer: req.headers['referer'],
origin: req.headers['origin'],
timestamp: new Date().toISOString(),
headers: {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip']
},
requestUrl: req.originalUrl,
rateLimitInfo: {
limit: req.rateLimit?.limit,
current: req.rateLimit?.current,
remaining: req.rateLimit?.remaining,
resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null
}
});
res.status(429).json({
error: 'Too many requests, please try again later.',
retryAfter: res.getHeader('Retry-After')
});
},
standardHeaders: true, // Return rate limit info in headers
legacyHeaders: false, // Disable X-RateLimit headers
});
}
/**
* Create auth-specific rate limiter
*/
async function createAuthRateLimiter() {
const config = await getRateLimitSettings();
return rateLimit({
windowMs: config.windowMinutes * 60 * 1000,
max: config.authMaxRequests,
keyGenerator: (req) => {
// Use correct client IP when behind proxy
return req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
},
skip: async () => {
const currentConfig = await getRateLimitSettings();
return !currentConfig.enabled;
},
handler: (req, res) => {
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
req.headers['x-real-ip'] ||
req.connection.remoteAddress ||
req.ip;
// Enhanced logging for auth failures
logger.warn('Auth rate limit exceeded', {
ip: clientIp,
path: req.path,
method: req.method,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
headers: {
'x-forwarded-for': req.headers['x-forwarded-for'],
'x-real-ip': req.headers['x-real-ip']
},
requestUrl: req.originalUrl,
authType: req.path.includes('admin') ? 'admin' : 'gallery',
rateLimitInfo: {
limit: req.rateLimit?.limit,
current: req.rateLimit?.current,
remaining: req.rateLimit?.remaining,
resetTime: req.rateLimit?.resetTime ? new Date(req.rateLimit.resetTime).toISOString() : null
}
});
res.status(429).json({
error: 'Too many authentication attempts, please try again later.',
retryAfter: res.getHeader('Retry-After')
});
},
standardHeaders: true,
legacyHeaders: false,
});
}
module.exports = {
getRateLimitSettings,
clearSettingsCache,
createRateLimiter,
createAuthRateLimiter,
isAuthenticated,
shouldSkipRateLimit
};
+24 -24
View File
@@ -56,30 +56,30 @@ class WatermarkService {
let left, top;
switch (position) {
case 'top-left':
left = padding;
top = padding;
break;
case 'top-right':
left = imageWidth - watermarkWidth - padding;
top = padding;
break;
case 'bottom-left':
left = padding;
top = imageHeight - watermarkHeight - padding;
break;
case 'bottom-right':
left = imageWidth - watermarkWidth - padding;
top = imageHeight - watermarkHeight - padding;
break;
case 'center':
left = Math.floor((imageWidth - watermarkWidth) / 2);
top = Math.floor((imageHeight - watermarkHeight) / 2);
break;
default:
// Default to bottom-right
left = imageWidth - watermarkWidth - padding;
top = imageHeight - watermarkHeight - padding;
case 'top-left':
left = padding;
top = padding;
break;
case 'top-right':
left = imageWidth - watermarkWidth - padding;
top = padding;
break;
case 'bottom-left':
left = padding;
top = imageHeight - watermarkHeight - padding;
break;
case 'bottom-right':
left = imageWidth - watermarkWidth - padding;
top = imageHeight - watermarkHeight - padding;
break;
case 'center':
left = Math.floor((imageWidth - watermarkWidth) / 2);
top = Math.floor((imageHeight - watermarkHeight) / 2);
break;
default:
// Default to bottom-right
left = imageWidth - watermarkWidth - padding;
top = imageHeight - watermarkHeight - padding;
}
return { left: Math.max(0, left), top: Math.max(0, top) };
+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);
+78
View File
@@ -0,0 +1,78 @@
const path = require('path');
const fs = require('fs').promises;
const logger = require('./logger');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/**
* Clean up old temporary upload directories
* Removes temp directories older than 1 hour
*/
async function cleanupTempUploads() {
const tempPath = path.join(getStoragePath(), 'temp');
try {
// Ensure temp directory exists
await fs.mkdir(tempPath, { recursive: true });
// Read all items in temp directory
const items = await fs.readdir(tempPath);
let cleanedCount = 0;
const oneHourAgo = Date.now() - (60 * 60 * 1000); // 1 hour
for (const item of items) {
const itemPath = path.join(tempPath, item);
try {
const stats = await fs.stat(itemPath);
// Only process directories that match our upload pattern
if (stats.isDirectory() && item.startsWith('upload_')) {
// Extract timestamp from directory name
const parts = item.split('_');
if (parts.length >= 2) {
const timestamp = parseInt(parts[1]);
// Remove if older than 1 hour
if (!isNaN(timestamp) && timestamp < oneHourAgo) {
logger.info(`Cleaning up old temp upload directory: ${item}`);
await fs.rm(itemPath, { recursive: true, force: true });
cleanedCount++;
}
}
}
} catch (error) {
logger.error(`Error processing temp item ${item}:`, error.message);
}
}
if (cleanedCount > 0) {
logger.info(`Cleaned up ${cleanedCount} old temp upload directories`);
}
} catch (error) {
logger.error('Error during temp upload cleanup:', error);
}
}
/**
* Start periodic cleanup of temp uploads
* Runs every hour
*/
function startTempUploadCleanup() {
// Run immediately on startup
cleanupTempUploads();
// Then run every hour
setInterval(() => {
cleanupTempUploads();
}, 60 * 60 * 1000); // 1 hour
logger.info('Temp upload cleanup service started');
}
module.exports = {
cleanupTempUploads,
startTempUploadCleanup
};
+64 -29
View File
@@ -11,9 +11,44 @@ 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;
const dateObj = date instanceof Date ? date : new Date(date);
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;
}
}
// Ensure proper date parsing
let dateObj;
if (date instanceof Date) {
dateObj = date;
} else if (typeof date === 'string') {
// For date strings like "2025-07-16", parse as local date to avoid timezone issues
if (date.match(/^\d{4}-\d{2}-\d{2}$/)) {
// Parse YYYY-MM-DD format as local date
const [year, month, day] = date.split('-').map(num => parseInt(num, 10));
dateObj = new Date(year, month - 1, day);
} else {
dateObj = new Date(date);
}
} else {
dateObj = new Date(date);
}
// Check if date is valid
if (isNaN(dateObj.getTime())) {
console.error('Invalid date provided to formatDate:', date);
throw new Error('Invalid date');
}
// Use appropriate locale based on language
let locale = dateConfig.locale || 'en-GB';
@@ -25,33 +60,33 @@ async function formatDate(date, language = 'en') {
// Format based on the configured format
switch (dateConfig.format) {
case 'MM/DD/YYYY':
return dateObj.toLocaleDateString(locale, {
month: '2-digit',
day: '2-digit',
year: 'numeric'
});
case 'DD/MM/YYYY':
return dateObj.toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
case 'YYYY-MM-DD':
return dateObj.toISOString().split('T')[0];
case 'DD.MM.YYYY':
return dateObj.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
default:
// Use long format as fallback
return dateObj.toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
case 'MM/DD/YYYY':
return dateObj.toLocaleDateString(locale, {
month: '2-digit',
day: '2-digit',
year: 'numeric'
});
case 'DD/MM/YYYY':
return dateObj.toLocaleDateString(locale, {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
case 'YYYY-MM-DD':
return dateObj.toISOString().split('T')[0];
case 'DD.MM.YYYY':
return dateObj.toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric'
});
default:
// Use long format as fallback
return dateObj.toLocaleDateString(locale, {
year: 'numeric',
month: 'long',
day: 'numeric'
});
}
} catch (error) {
console.error('Error formatting date:', error);
+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
};

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