Compare commits

..

60 Commits

Author SHA1 Message Date
Gitea Actions Bot c1068bbc51 chore: bump frontend version to 1.0.76
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is failing
2025-07-22 07:20:29 +00:00
paul b6fb712c9f fix: correct import statements for api in backup JSX files
Mirror to GitHub / mirror (push) Successful in 36s
Test and Lint / backend-test (push) Successful in 1m29s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m28s
Version and Release / version-bump (push) Successful in 57s
Version and Release / trigger-drone (push) Successful in 3s
- Change default import to named import for api from config/api.ts
- Fixes build error: 'default' is not exported by src/config/api.ts
- Affected files: BackupHistory.jsx, RestoreWizard.jsx, BackupManagement.jsx

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 09:14:23 +02:00
Gitea Actions Bot 3cb8408207 chore: bump version to 1.0.75 (backend + frontend)
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2025-07-22 07:11:13 +00:00
paul 20dd43c093 feat: implement comprehensive backup and restore system with S3 support
Mirror to GitHub / mirror (push) Successful in 28s
Test and Lint / backend-test (push) Successful in 1m26s
continuous-integration/drone/push Build is failing
Test and Lint / frontend-test (push) Failing after 2m18s
Version and Release / version-bump (push) Successful in 42s
Version and Release / trigger-drone (push) Successful in 3s
- Add S3/MinIO storage adapter with multipart upload support
- Implement database backup service for SQLite and PostgreSQL
- Create backup manifest generator for tracking backup contents
- Enhance backup service with S3 integration and incremental backups
- Add restore service with safety measures and rollback capability
- Create comprehensive test suite for all backup functionality
- Add admin API endpoints for backup/restore management
- Implement frontend UI with dashboard, configuration, and restore wizard
- Add roadmap section to README with implemented backup feature

This implementation provides:
- Multiple backup destinations (local, rsync, S3/MinIO)
- Intelligent change detection to minimize backup frequency
- Full database backups with compression
- Manifest-based restore with integrity validation
- Pre-restore safety backups with rollback
- Comprehensive error handling and monitoring
- User-friendly admin interface

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-22 09:05:52 +02:00
paul 977839156b update CI\CD
Mirror to GitHub / mirror (push) Successful in 28s
Test and Lint / backend-test (push) Successful in 1m15s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m18s
Merge branch 'main' of https://gitea.nothaft.cloud/paul/picpeak
2025-07-21 21:24:42 +02:00
paul af4f63f561 ci: publish images to GHCR and create GitHub release via Drone 2025-07-21 21:24:26 +02:00
Gitea Actions Bot cdff4ebb8a chore: bump version to 1.0.74 (backend + frontend)
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-21 17:40:15 +00:00
paul 59f958b085 chore: consolidate and update deployment documentation
Mirror to GitHub / mirror (push) Successful in 28s
Test and Lint / backend-test (push) Successful in 1m4s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m20s
Version and Release / version-bump (push) Successful in 42s
Version and Release / trigger-drone (push) Successful in 4s
- Remove completed PRODUCTION_TODO_LIST.md
- Consolidate deployment guides: keep comprehensive PRODUCTION_DEPLOYMENT_GUIDE.md, remove redundant PRODUCTION_DEPLOYMENT.md
- Update all .env.example files to reflect current system:
  - Remove deprecated ADMIN_EMAIL/ADMIN_PASSWORD (now auto-generated)
  - Add proper documentation for all environment variables
  - Clarify that Umami config is optional (primary via Admin UI)
  - Add realistic examples for SMTP providers
  - Update ports to match actual defaults (3001)
- Update PRODUCTION_DEPLOYMENT_GUIDE.md:
  - Document auto-generated admin credentials process
  - Add Traefik configuration section
  - Update security checklist with current features
  - Fix outdated environment variables
  - Add nginx proxy configuration details

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 19:35:22 +02:00
paul 097d7a0b65 git statusMerge branch 'main' of https://gitea.nothaft.cloud/paul/picpeak
Mirror to GitHub / mirror (push) Successful in 27s
Test and Lint / backend-test (push) Successful in 1m15s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m22s
Update workflows
2025-07-21 17:31:57 +02:00
paul 1273777541 feat: exclude Claude contributor from GitHub mirror workflow 2025-07-21 17:31:39 +02:00
Gitea Actions Bot b6a960879f chore: bump frontend version to 1.0.73
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-07-21 15:28:33 +00:00
paul 4bd153104b Merge branch 'main' of https://gitea.nothaft.cloud/paul/picpeak
Mirror to GitHub / mirror (push) Successful in 35s
Test and Lint / backend-test (push) Successful in 1m30s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m30s
Version and Release / version-bump (push) Successful in 45s
Version and Release / trigger-drone (push) Successful in 3s
2025-07-21 17:23:02 +02:00
paul e099fcf600 fix: use admin API for Umami config in analytics page
- Changed from public settings endpoint to admin settings endpoint
- Fixed "Unexpected token '<'" JSON parse error
- Properly transforms settings array to key-value map
- Uses correct setting keys (analytics_umami_*)
- Maintains fallback to environment variables

The analytics page now correctly fetches Umami configuration using
the authenticated admin API instead of the public endpoint, which
was returning errors and causing JSON parse failures.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-21 16:53:31 +02:00
Gitea Actions Bot 89fdf401c0 chore: bump frontend version to 1.0.72
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is passing
2025-07-20 20:32:41 +00:00
paul 9cc46a1819 feat: completely rewrite GitHub mirror to create new history from target commit
Mirror to GitHub / mirror (push) Successful in 1m49s
continuous-integration/drone/push Build is failing
Test and Lint / backend-test (push) Successful in 2m12s
Test and Lint / frontend-test (push) Successful in 2m45s
BREAKING: This completely replaces the previous approach and will DELETE
all existing history on GitHub, creating entirely new commit SHAs.

Key changes:
- Use orphan branch instead of cherry-pick to break history connection
- Create initial commit from target commit tree using git read-tree
- Apply subsequent changes as completely new commits with new SHAs
- Force push will COMPLETELY REPLACE GitHub history
- No trace of commits before 7aca927937 will remain on GitHub

This ensures GitHub shows only history from the target commit onwards
with no connection to previous commits or their metadata.
2025-07-20 22:30:57 +02:00
paul f7c0e5f51a fix: improve version bump workflow with better conflict resolution
Mirror to GitHub / mirror (push) Has been cancelled
Test and Lint / backend-test (push) Has been cancelled
Test and Lint / frontend-test (push) Has been cancelled
continuous-integration/drone/push Build is passing
- Added pre-fetch and check before committing to ensure we're up-to-date
- Improved retry logic with clearer output and better error handling
- Added explicit fetch before each retry attempt
- Use for-loop instead of while for clearer retry counting
- Better fallback from rebase to merge on conflicts
- Added set -e to fail fast on errors
- More verbose logging for debugging

This should resolve the persistent "non-fast-forward" errors by:
1. Checking if we're behind before even committing
2. Pulling changes if needed
3. Retrying with proper synchronization
4. Providing clear debug output

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 22:28:05 +02:00
paul 200581e73c CRITICAL FIX: Remove 403 from auth redirect logic to restore login
Mirror to GitHub / mirror (push) Successful in 1m2s
Test and Lint / frontend-test (push) Has been cancelled
Test and Lint / backend-test (push) Has started running
continuous-integration/drone/push Build is passing
Version and Release / version-bump (push) Successful in 3m36s
Version and Release / trigger-drone (push) Successful in 6s
BREAKING ISSUE FIXED:
- 403 errors were triggering redirects, preventing login page from loading
- Public endpoints returning 403 were causing redirect loops

Changes:
- Removed 403 status from automatic redirect logic
- Only 401 (Unauthorized) now triggers login redirect
- 403 (Forbidden) errors are passed through without redirect

This fixes the critical issue where users couldn't access the login page
because public API calls were returning 403 and triggering redirects.

403 errors should be handled differently than 401:
- 401 = Missing/invalid auth (redirect to login)
- 403 = Forbidden (could be rate limit, IP block, etc - don't redirect)

🚨 Emergency fix for production

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 22:24:25 +02:00
paul 505acf833e fix: resolve SIGPIPE error in GitHub mirror workflow file cleanup
Mirror to GitHub / mirror (push) Successful in 1m23s
continuous-integration/drone/push Build is passing
Test and Lint / backend-test (push) Successful in 2m6s
Test and Lint / frontend-test (push) Has started running
- Replace problematic 'find | head -20' commands that caused exit code 141
- Use 'ls -la | head -10 || true' for safer file listing
- Add better progress logging during sensitive file removal
- Add error handling with '|| true' to prevent pipe failures

The find command was outputting more than head could handle, causing
SIGPIPE when head closed the pipe early. This fix uses ls which is
more predictable and adds proper error handling.
2025-07-20 22:17:47 +02:00
paul 1ac5b0447a fix: resolve GitHub mirror workflow cherry-pick failure with merge commits
Mirror to GitHub / mirror (push) Failing after 1m27s
Test and Lint / frontend-test (push) Has been cancelled
Test and Lint / backend-test (push) Has started running
- Add --no-merges flag to exclude merge commits during cherry-pick
- Improve error handling for cherry-pick conflicts with auto-resolution
- Add reporting of skipped merge commits for transparency
- Enhance logging to show detailed progress during commit application

Fixes the workflow failure caused by trying to cherry-pick merge commits
which require special handling that was causing exit code 128.
2025-07-20 22:13:55 +02:00
paul 15a2fc2d5f fix: resolve CI/CD version bump race condition
Mirror to GitHub / mirror (push) Failing after 27s
Test and Lint / backend-test (push) Successful in 2m19s
Test and Lint / frontend-test (push) Has been cancelled
continuous-integration/drone/push Build is passing
- Added pull before push to handle concurrent workflow executions
- Implemented retry logic with 3 attempts for push operations
- Added fallback from rebase to merge if conflicts occur
- Added proper error handling and logging for debugging

This fixes the "non-fast-forward" error that occurs when multiple
workflows run simultaneously and try to push version bumps.

The workflow now:
1. Pulls latest changes before pushing
2. Retries up to 3 times with 5-second delays
3. Falls back to merge if rebase fails
4. Provides clear error messages for debugging

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 22:10:04 +02:00
paul a1cf6a1156 feat: update GitHub mirror workflow to start history from specific commit
Mirror to GitHub / mirror (push) Failing after 34s
Test and Lint / backend-test (push) Has started running
Test and Lint / frontend-test (push) Has been cancelled
continuous-integration/drone/push Build is failing
- Start history from commit 7aca927937 instead of orphan branch
- Use cherry-pick to preserve meaningful commit history
- Automatically exclude files that only existed before target commit
- Add comprehensive error handling and logging
- Maintain clean linear history for GitHub repository
2025-07-20 22:08:53 +02:00
paul 481545c37b feat: enhance mirror-to-github workflow with commit-based history filtering
Mirror to GitHub / mirror (push) Successful in 29s
Test and Lint / backend-test (push) Successful in 1m17s
continuous-integration/drone/push Build is failing
Test and Lint / frontend-test (push) Successful in 2m29s
- Replace orphan branch approach with commit-based filtering from cfa29ad5cb
- Add automatic removal of sensitive files (env, logs, gitea configs)
- Implement robust git operations with fallback mechanisms
- Add comprehensive debugging and error handling
- Ensure same security exclusions as manual process
2025-07-20 22:00:07 +02:00
paul a67df87013 fix: handle auth errors and JSON parsing in admin panel
Mirror to GitHub / mirror (push) Successful in 32s
Test and Lint / frontend-test (push) Has been cancelled
Test and Lint / backend-test (push) Has started running
continuous-integration/drone/push Build is passing
Version and Release / version-bump (push) Failing after 1m17s
Version and Release / trigger-drone (push) Failing after 11m37s
- Added proper HTTP status check before JSON parsing in AnalyticsPage
  * Prevents "Unexpected token '<'" error when API returns HTML error pages
  * Throws proper error for non-OK responses

- Enhanced API error handling to treat 403 as auth failure
  * Both 401 and 403 now trigger redirect to login page
  * Clears expired admin tokens automatically
  * Prevents users from staying on admin pages with expired sessions

These fixes resolve:
1. JSON parse errors when fetching Umami config
2. 403 Forbidden errors not redirecting to login
3. Backend version display issues due to auth failures

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 21:58:53 +02:00
paul d2dbe2ea2f Merge branch 'main' of https://gitea.nothaft.cloud/paul/picpeak
Mirror to GitHub / mirror (push) Successful in 39s
Test and Lint / backend-test (push) Successful in 1m19s
Test and Lint / frontend-test (push) Successful in 2m18s
Version and Release / version-bump (push) Successful in 36s
Version and Release / trigger-drone (push) Has been skipped
continuous-integration/drone/push Build is passing
2025-07-20 21:19:38 +02:00
paul 98ea5e7202 chore: add production todo list and update CI/CD configs
- Added PRODUCTION_TODO_LIST.md with 9 completed production fixes
- Updated .gitea/workflows/mirror-to-github.yml
- Updated .gitignore

This commit includes all the production fixes implemented:
1. Password complexity settings
2. Gallery login security improvements
3. Analytics configuration fixes
4. Translation additions
5. UI/UX improvements
6. Date format consistency
7. Chrome compatibility fixes

All tasks have been completed and tested for production deployment.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 21:19:18 +02:00
Gitea Actions Bot 6b5c08e99b chore: bump version to 1.0.71 (backend + frontend)
continuous-integration/drone/push Build is failing
continuous-integration/drone/tag Build is failing
2025-07-20 19:07:39 +00:00
paul 11ecad136b Merge branch 'main' of https://gitea.nothaft.cloud/paul/picpeak
Mirror to GitHub / mirror (push) Successful in 41s
Test and Lint / backend-test (push) Successful in 1m21s
Test and Lint / frontend-test (push) Successful in 2m23s
Version and Release / version-bump (push) Successful in 52s
Version and Release / trigger-drone (push) Successful in 5s
continuous-integration/drone/push Build is failing
2025-07-20 21:01:27 +02:00
paul 3a4dccd9f0 fix: resolve production UI and API issues
- Fixed backend version endpoint by adding retry logic import
- Gallery login page improvements:
  * Increased title size from text-xl to text-2xl (responsive scaling)
  * Title now uses event's custom primary color (var(--color-primary))
  * Removed event category badge from login page
- Fixed Umami analytics configuration check:
  * Added proper enabled state tracking
  * Warning now only shows when Umami is explicitly not configured
  * Checks both admin settings and environment variables properly

These changes improve user experience and fix false warnings in production.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 21:00:43 +02:00
Gitea Actions Bot 0a5e55ca96 chore: bump backend version to 1.0.70
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-20 19:00:09 +00:00
paul a72741c0d9 Merge branch 'main' of https://gitea.nothaft.cloud/paul/picpeak
Version and Release / trigger-drone (push) Blocked by required conditions
Mirror to GitHub / mirror (push) Successful in 40s
Test and Lint / backend-test (push) Successful in 1m28s
Test and Lint / frontend-test (push) Successful in 2m48s
Version and Release / version-bump (push) Successful in 1m4s
2025-07-20 20:52:38 +02:00
paul e7ed7006fd fix: critical database connection pool exhaustion issues
- Disabled duplicate email service (emailService.js) that was creating redundant connections
- Increased connection pool size from 10 to 25 for production environment
- Extended session timeout cache from 5 to 30 minutes to reduce DB queries
- Added connection retry logic with exponential backoff for transient failures
- Fixed password validation to use retry wrapper and correct setting key
- Updated public settings and gallery middleware to handle connection failures gracefully

These changes address the "Connection terminated unexpectedly" errors in production by:
1. Reducing unnecessary database connections
2. Increasing available connection pool capacity
3. Implementing automatic retry for transient connection failures
4. Caching frequently accessed data for longer periods

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 20:51:44 +02:00
Gitea Actions Bot 3d3013d9d6 chore: bump version to 1.0.69 (backend + frontend)
continuous-integration/drone/tag Build is passing
continuous-integration/drone/push Build is passing
2025-07-20 18:35:36 +00:00
paul bccaa649dc Merge branch 'main' of https://gitea.nothaft.cloud/paul/picpeak
Mirror to GitHub / mirror (push) Successful in 38s
Test and Lint / backend-test (push) Successful in 1m18s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m22s
Version and Release / version-bump (push) Successful in 49s
Version and Release / trigger-drone (push) Successful in 6s
2025-07-20 20:29:16 +02:00
paul 7aca927937 fix: implement 9 production enhancements and security fixes
- Password Complexity: Added 4-level complexity selector (Simple/Moderate/Strong/Very Strong) in admin security settings with dynamic backend validation
- Gallery Security: Removed event date from login page (security risk), replaced with event type badge
- Analytics Config: Fixed "Not Configured" detection logic to check both admin settings and env variables
- Analytics Accuracy: Aligned calculation logic between dashboard and analytics endpoints, added totals verification
- Translations: Added missing activity keys (analytics_settings_updated, cms_page_updated, security_settings_updated, password_reset, admin_logout, system_activity)
- UI Fixes: Fixed German text overflow in CMS page selector with proper CSS truncation
- Date Format: Event creation now respects admin-configured date format instead of browser locale
- Chrome Compatibility: Replaced emoji flags with SVG components for Windows Chrome support

All changes maintain backward compatibility and production stability.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 20:27:28 +02:00
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
115 changed files with 20009 additions and 786 deletions
+49
View File
@@ -72,6 +72,55 @@ steps:
context: frontend/
registry: registry.local.nothaft.cloud
# -------- NEW: Publish Docker images to GitHub Container Registry --------
- name: push-backend-ghcr
image: plugins/docker
settings:
repo: ghcr.io/the-luap/picpeak-backend
tags:
- ${DRONE_TAG}
- latest
dockerfile: backend/Dockerfile
context: backend/
registry: ghcr.io
username:
from_secret: GITHUB_USERNAME
password:
from_secret: GITHUB_TOKEN
build_args:
- VERSION=${DRONE_TAG}
- name: push-frontend-ghcr
image: plugins/docker
settings:
repo: ghcr.io/the-luap/picpeak-frontend
tags:
- ${DRONE_TAG}
- latest
dockerfile: frontend/Dockerfile
context: frontend/
registry: ghcr.io
username:
from_secret: GITHUB_USERNAME
password:
from_secret: GITHUB_TOKEN
build_args:
- VERSION=${DRONE_TAG}
- VITE_API_URL=${VITE_API_URL:-/api}
# -------- NEW: Create GitHub Release --------
- name: github-release
image: plugins/github-release
settings:
api_key:
from_secret: GITHUB_TOKEN
files: ["CHANGELOG.md"]
title: "PicPeak ${DRONE_TAG}"
note: |
Release ${DRONE_TAG}
See CHANGELOG.md for full details.
trigger:
event:
- tag
+31 -18
View File
@@ -1,26 +1,24 @@
# Environment Configuration Template
# Copy this file to .env and adjust values for your environment
# PicPeak Development Environment Configuration
# Copy this file to .env for local development
# Development: Use docker-compose.dev.yml
# Production: Use docker-compose.prod.yml with .env.production.example
# SECURITY WARNING: This configuration is for development only!
# For production, use .env.production.example
# JWT Secret (CRITICAL for production)
# Generate with: openssl rand -base64 32
JWT_SECRET=dev-secret-change-in-production
# JWT Secret (Change in production!)
# Generate secure secret with: openssl rand -base64 32
JWT_SECRET=dev-secret-DO-NOT-USE-IN-PRODUCTION
# Application URLs
# Application URLs (Docker Compose development setup)
ADMIN_URL=http://localhost:3005
FRONTEND_URL=http://localhost:3005
BACKEND_URL=http://localhost:3001
# Database Configuration
# SQLite is used for development by default
# For production PostgreSQL config, see .env.production.example
# Database Configuration (SQLite for development)
DATABASE_CLIENT=sqlite3
DATABASE_PATH=./data/photo_sharing.db
# Email Configuration
# Development: Uses Mailhog (included in docker-compose.dev.yml)
# Production: Configure real SMTP server
# Email Configuration (Mailhog for development)
# Access Mailhog UI at: http://localhost:8025
SMTP_HOST=mailhog
SMTP_PORT=1025
SMTP_SECURE=false
@@ -28,7 +26,22 @@ SMTP_USER=
SMTP_PASS=
EMAIL_FROM=noreply@localhost
# Optional: Umami Analytics
UMAMI_URL=
UMAMI_WEBSITE_ID=
UMAMI_HASH_SALT=
# Backend Port Configuration
PORT=3001
# Optional: Umami Analytics Backend Config
# NOTE: Primary configuration through Admin UI > Settings > Analytics
# These are fallback values for server-side tracking
# UMAMI_URL=https://analytics.example.com
# UMAMI_WEBSITE_ID=your-website-id
# UMAMI_HASH_SALT=your-hash-salt
# Development Features
NODE_ENV=development
LOG_LEVEL=debug
# Admin Setup Notes:
# 1. Run 'npm run migrate' in backend folder
# 2. Admin credentials will be auto-generated
# 3. Check ADMIN_CREDENTIALS.txt for login details
# 4. Change password on first login (required)
+83 -27
View File
@@ -1,44 +1,100 @@
# PicPeak Production Configuration
# Copy this file to .env and update with your values
# Copy this file to .env and update with your production values
# Required: Security
JWT_SECRET=CHANGE_THIS_TO_RANDOM_32_CHAR_STRING
# ============================================
# CRITICAL SECURITY - MUST CHANGE ALL VALUES!
# ============================================
# Required: URLs (update with your domain)
# JWT Secret - REQUIRED (minimum 32 characters)
# Generate with: openssl rand -base64 32
JWT_SECRET=CHANGE-THIS-PRODUCTION-SECRET-USE-OPENSSL-COMMAND
# Application URLs - REQUIRED (your actual domain)
FRONTEND_URL=https://your-domain.com
BACKEND_URL=https://your-domain.com
ADMIN_URL=https://your-domain.com
# Required: Email Settings
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=your-email@gmail.com
# ============================================
# DATABASE CONFIGURATION - REQUIRED
# ============================================
# Required: Initial Admin Account
ADMIN_EMAIL=admin@your-domain.com
ADMIN_PASSWORD=change-this-password
# Database (PostgreSQL recommended for production)
# PostgreSQL Configuration (Recommended for production)
DATABASE_CLIENT=pg
DB_HOST=postgres
DB_HOST=postgres # or your database host
DB_PORT=5432
DB_NAME=picpeak
DB_USER=picpeak
DB_PASSWORD=secure-database-password
DB_PASSWORD=CHANGE-THIS-SECURE-DATABASE-PASSWORD
# Optional: Customization
SITE_NAME=PicPeak
DEFAULT_EXPIRATION_DAYS=30
SESSION_TIMEOUT_MINUTES=60
# ============================================
# EMAIL CONFIGURATION - REQUIRED
# ============================================
# Optional: Analytics (Umami)
VITE_UMAMI_URL=
VITE_UMAMI_WEBSITE_ID=
# Example: Gmail with App Password
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=your-email@gmail.com
# SMTP_PASS=your-16-char-app-password
# EMAIL_FROM=Your Name <your-email@gmail.com>
# Example: SendGrid
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=apikey
SMTP_PASS=YOUR-SENDGRID-API-KEY
EMAIL_FROM=PicPeak <noreply@your-domain.com>
# ============================================
# ADMIN SETUP - AUTO-GENERATED
# ============================================
# NOTE: Admin credentials are automatically generated during setup
# DO NOT set ADMIN_EMAIL or ADMIN_PASSWORD anymore!
# Run 'npm run migrate' and check ADMIN_CREDENTIALS.txt
# ============================================
# OPTIONAL CONFIGURATION
# ============================================
# Umami Analytics (Optional - Fallback values)
# Primary config via Admin UI > Settings > Analytics
# UMAMI_URL=https://analytics.your-domain.com
# UMAMI_WEBSITE_ID=your-website-id
# UMAMI_HASH_SALT=your-hash-salt
# Frontend Analytics (Optional - Fallback values)
# VITE_UMAMI_URL=https://analytics.your-domain.com
# VITE_UMAMI_WEBSITE_ID=your-website-id
# VITE_UMAMI_SHARE_URL=https://analytics.your-domain.com/share/xyz/gallery
# ============================================
# PERFORMANCE & SECURITY TUNING
# ============================================
# Advanced: Performance Tuning
NODE_ENV=production
PORT=3001
LOG_LEVEL=info
# Security Settings (Defaults are secure)
BCRYPT_ROUNDS=12
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
SESSION_TIMEOUT_MINUTES=60
RATE_LIMIT_WINDOW_MS=900000 # 15 minutes
RATE_LIMIT_MAX_REQUESTS=100 # per window
# Connection Pool (Adjust based on load)
DB_POOL_MIN=5
DB_POOL_MAX=25
# ============================================
# DOCKER COMPOSE SPECIFIC
# ============================================
# Traefik Configuration (if using Traefik)
DOMAIN=your-domain.com
LETSENCRYPT_EMAIL=admin@your-domain.com
# Volume Paths (Docker)
STORAGE_PATH=/app/storage
EVENTS_PATH=/app/storage/events
ARCHIVE_PATH=/app/storage/events/archived
+154 -33
View File
@@ -10,10 +10,10 @@ jobs:
mirror:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
- name: Checkout repository with full history
uses: actions/checkout@v3
with:
fetch-depth: 0 # Full history needed for mirroring
fetch-depth: 0 # Full history needed for finding the commit
- name: Setup Git
run: |
@@ -28,40 +28,153 @@ jobs:
git status
echo "Remote info:"
git remote -v
echo "Checking target commit exists:"
git show --oneline 7aca927937 || echo "Target commit not found!"
- name: Create filtered branch
- name: Create completely new history from specific commit
run: |
TARGET_COMMIT="7aca927937"
# Verify the target commit exists
if ! git cat-file -e $TARGET_COMMIT^{commit}; then
echo "ERROR: Target commit $TARGET_COMMIT does not exist!"
exit 1
fi
echo "✅ Target commit found: $(git log --oneline -1 $TARGET_COMMIT)"
# Clean up any existing github-mirror branch
git branch -D github-mirror || true
# Create a new branch for GitHub
# Create a completely new orphan branch (no history)
git checkout --orphan github-mirror
# Remove sensitive files/directories
# Example: Remove .env files, private configs, etc.
git rm -r --cached .env* || true
git rm -r --cached backend/.env* || true
git rm -r --cached frontend/.env* || true
git rm -r --cached docker-compose.prod.yml || true
git rm -r --cached .claudedocs/ || true
git rm -r --cached backend/data/ || true
git rm -r --cached backend/storage/ || true
git rm -r --cached .gitea/ || true
git rm -r --cached scripts/install-gitea-runner.sh || true
git rm -r --cached .drone* || true
git rm -r --cached .github-mirror-exclude || true
git rm -r --cached .gitattributes-github || true
git rm -r --cached photo-sharing-prd.md || true
git rm -r --cached CLAUDE.md || true
git rm -r --cached PRODUCTION_DEPLOYMENT_GUIDE.md || true
git rm -r --cached logs/ || true
git rm -r --cached frontend/.claudedocs/ || true
git rm -r --cached test-maintenance.sh || true
git rm -r --cached storage/ || true
# Clear the staging area completely
git rm -rf . || true
# Get the file tree from the target commit and create initial commit
echo "Creating new history starting from $TARGET_COMMIT..."
git read-tree $TARGET_COMMIT
git commit -m "Initial commit - imported from $(git log --oneline -1 $TARGET_COMMIT)"
echo "✅ Created new initial commit: $(git log --oneline -1)"
# Now get all commits after the target commit and apply their changes
COMMITS_AFTER_TARGET=$(git rev-list --reverse --no-merges $TARGET_COMMIT..main)
if [ -n "$COMMITS_AFTER_TARGET" ]; then
echo "📋 Applying changes from commits after $TARGET_COMMIT (excluding Claude commits):"
for commit in $COMMITS_AFTER_TARGET; do
# Get the commit author name
COMMIT_AUTHOR_NAME=$(git log --format="%an" -n 1 $commit)
# Skip commits by Claude
if [ "$COMMIT_AUTHOR_NAME" = "Claude" ]; then
echo "⚠️ Skipping commit by Claude: $(git log --oneline -1 $commit)"
continue
fi
echo "Processing: $(git log --oneline -1 $commit)"
# Get the commit message and author info
COMMIT_MSG=$(git log --format="%B" -n 1 $commit)
COMMIT_AUTHOR=$(git log --format="%an <%ae>" -n 1 $commit)
COMMIT_DATE=$(git log --format="%ad" -n 1 $commit)
# Apply the changes from this commit
if git diff-tree --no-commit-id --name-only -r $commit | xargs -I {} git show $commit:{} > /dev/null 2>&1; then
# Apply file changes
git checkout $commit -- . || true
# Stage all changes
git add -A
# Only commit if there are changes
if ! git diff --cached --quiet; then
# Create new commit with original metadata but new SHA
GIT_AUTHOR_NAME=$(echo "$COMMIT_AUTHOR" | cut -d'<' -f1 | xargs)
GIT_AUTHOR_EMAIL=$(echo "$COMMIT_AUTHOR" | cut -d'<' -f2 | cut -d'>' -f1)
GIT_AUTHOR_DATE="$COMMIT_DATE"
export GIT_AUTHOR_NAME GIT_AUTHOR_EMAIL GIT_AUTHOR_DATE
git commit -m "$COMMIT_MSG"
echo "✅ Applied changes as new commit: $(git log --oneline -1)"
else
echo "⚠️ No changes to commit for $commit"
fi
else
echo "⚠️ Skipping problematic commit $commit"
fi
done
echo "✅ Finished creating new history"
else
echo "✅ No commits after target commit - history starts fresh"
fi
echo ""
echo "=== New History Summary ==="
echo "Total commits in new history: $(git rev-list --count github-mirror)"
echo "History starts with: $(git log --oneline --reverse | head -1)"
echo "Latest commit: $(git log --oneline -1)"
# Commit the changes
git commit -m "Remove sensitive files for GitHub mirror" || true
- name: Remove sensitive files and directories
run: |
# Switch to the github-mirror branch
git checkout github-mirror
echo "Current files before cleanup:"
ls -la | head -10 || true
echo "..."
# Remove sensitive files/directories if they exist
echo "Removing sensitive files..."
rm -rf .env || true
rm -rf backend/.env* || true
rm -rf frontend/.env* || true
rm -rf docker-compose.prod.yml || true
rm -rf .claudedocs/ || true
rm -rf backend/data/ || true
rm -rf backend/storage/ || true
rm -rf .gitea/ || true
rm -rf scripts/install-gitea-runner.sh || true
rm -rf .drone* || true
rm -rf .github-mirror-exclude || true
rm -rf .gitattributes-github || true
rm -rf photo-sharing-prd.md || true
rm -rf CLAUDE.md || true
rm -rf PRODUCTION_DEPLOYMENT_GUIDE.md || true
rm -rf logs/ || true
rm -rf frontend/.claudedocs/ || true
rm -rf test-maintenance.sh || true
rm -rf storage/ || true
echo "Sensitive files removal completed"
# Add and commit the cleanup if there are changes
git add -A
if ! git diff --cached --quiet; then
git commit -m "chore: remove sensitive files for GitHub mirror"
echo "✅ Committed cleanup of sensitive files"
else
echo "✅ No sensitive files to remove"
fi
echo "Final file structure (top level):"
ls -la | head -10 || true
- name: Verify completely new history
run: |
git checkout github-mirror
echo "=== Final History Verification ==="
echo "Total commits in new github-mirror branch: $(git rev-list --count github-mirror)"
echo ""
echo "Complete commit history (should start from target commit content):"
git log --oneline --reverse
echo ""
echo "⚠️ Note: This is a completely NEW history with new commit SHAs"
echo "🔍 Original target commit content preserved but with new commit ID"
- name: Check GitHub token
env:
@@ -74,10 +187,13 @@ jobs:
echo "GitHub token is available (length: ${#GITHUBTOKEN})"
fi
- name: Push to GitHub
- name: Force push completely new history to GitHub
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
run: |
# Switch to github-mirror branch
git checkout github-mirror
# Remove existing github remote if it exists
git remote remove github || true
@@ -88,12 +204,17 @@ jobs:
echo "GitHub remote added:"
git remote -v
# Force push the filtered branch to GitHub main
echo "Pushing to GitHub..."
# Force push the completely new history to GitHub main
echo "🔥 FORCE PUSHING completely new history to GitHub..."
echo "⚠️ This will COMPLETELY REPLACE all history on GitHub!"
git push github github-mirror:main --force
echo "Push completed successfully!"
echo "✅ Force push completed - GitHub now has completely new history!"
- name: Workflow completed
run: |
echo "✅ Mirror to GitHub workflow completed successfully!"
echo "Check https://github.com/the-luap/picpeak to verify the mirror."
echo "🔥 COMPLETE HISTORY REPLACEMENT: GitHub now has entirely new history"
echo "📊 History starts from commit content: 7aca927937"
echo "🔍 Check https://github.com/the-luap/picpeak to verify the new history"
echo "📈 Total commits pushed: $(git rev-list --count github-mirror)"
echo "🆕 All commit SHAs are NEW - no connection to previous history"
+61 -3
View File
@@ -164,11 +164,25 @@ jobs:
- name: Commit version bump
if: steps.version.outputs.version_changed == 'true'
run: |
set -e # Exit on any error
# First, ensure we have the latest changes
echo "Fetching latest changes..."
git fetch origin main
# Check if we're behind and need to update
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [ "$LOCAL" != "$REMOTE" ]; then
echo "Local is behind remote, pulling changes..."
git pull origin main --no-rebase
fi
COMPONENT="${{ steps.version.outputs.component_changed }}"
if [ "$COMPONENT" = "both" ]; then
git add backend/package.json backend/package-lock.json
git add frontend/package.json frontend/package-lock.json
git add backend/package.json backend/package-lock.json frontend/package.json frontend/package-lock.json
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)"
elif [ "$COMPONENT" = "backend" ]; then
git add backend/package.json backend/package-lock.json
@@ -178,7 +192,51 @@ jobs:
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
fi
git push
# Pull latest changes before pushing to avoid conflicts
echo "Pulling latest changes from origin/main..."
if ! git pull --rebase origin main; then
echo "Rebase failed, attempting to resolve..."
# If rebase fails, abort and try a regular merge
git rebase --abort || true
git pull origin main --no-rebase
fi
# Push the changes with retry logic
echo "Pushing version bump..."
PUSH_SUCCESS=false
for i in 1 2 3; do
echo "Push attempt $i of 3..."
# Try to push
if git push origin main 2>&1; then
echo "Successfully pushed version bump on attempt $i"
PUSH_SUCCESS=true
break
else
echo "Push failed on attempt $i"
if [ $i -lt 3 ]; then
echo "Waiting 5 seconds before retry..."
sleep 5
echo "Pulling latest changes..."
git fetch origin main
# Try rebase first, fall back to merge
if ! git rebase origin/main; then
echo "Rebase failed, trying merge..."
git rebase --abort 2>/dev/null || true
git pull origin main --no-rebase
fi
fi
fi
done
if [ "$PUSH_SUCCESS" = "false" ]; then
echo "ERROR: Failed to push after 3 attempts"
exit 1
fi
- name: Create Git tag
if: steps.version.outputs.version_changed == 'true'
+2
View File
@@ -54,3 +54,5 @@ coverage/
!storage/thumbnails/.gitkeep
!data/.gitkeep
!logs/.gitkeep
PRODUCTION_DEPLOYMENT_GUIDE.md
+105 -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
@@ -111,6 +117,7 @@ Background services run as separate processes:
- **archiveService**: Creates ZIP archives of expired events
- **expirationChecker**: Cron job for expiration warnings
- **fileWatcher**: Monitors for new photo uploads
- **backupService**: Scheduled backups with checksum-based change detection
### API Structure
- `/api/admin/*` - Admin panel endpoints (requires adminAuth)
@@ -127,6 +134,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)
@@ -253,9 +295,71 @@ const { theme, setTheme, setThemeByName } = useTheme();
--border-radius: 0.5rem;
```
## Backup Service
### Overview
The backup service provides automated, scheduled backups of all photo data with checksum-based change detection to minimize transfer overhead.
### Features
- **Multiple Destinations**: Local directory, remote server (rsync), S3-compatible storage
- **Change Detection**: SHA256 checksums track file changes, only modified files are backed up
- **Scheduled Execution**: Configurable cron-based scheduling (default: 2 AM daily)
- **Email Notifications**: Alerts on backup failure, optional success notifications
- **Retention Management**: Automatic cleanup of old backup runs based on retention policy
- **Progress Tracking**: Database storage of backup history, file states, and statistics
### Configuration
Backup settings are stored in `app_settings` table with `backup_` prefix:
- `backup_enabled`: Enable/disable the service
- `backup_schedule`: Cron expression (e.g., '0 2 * * *')
- `backup_destination_type`: 'local', 'rsync', or 's3'
- `backup_retention_days`: How long to keep backup history
- `backup_include_archived`: Whether to backup archived events
- `backup_exclude_patterns`: File patterns to exclude
### API Endpoints
- `GET /api/admin/backup/config` - Get current configuration
- `PUT /api/admin/backup/config` - Update configuration
- `GET /api/admin/backup/status` - Get backup status and history
- `POST /api/admin/backup/run` - Trigger manual backup
- `POST /api/admin/backup/test-connection` - Test destination connectivity
### Testing
Run backup service test: `npm run test-backup`
### Database Tables
- `backup_runs`: Tracks each backup execution with statistics
- `backup_file_states`: Stores file checksums for change detection
## Success Metrics (from PRD)
- Time to generate gallery: <2 minutes
- 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
-98
View File
@@ -1,98 +0,0 @@
# Production Deployment Guide
This guide explains how to deploy PicPeak in production behind a reverse proxy like Traefik.
## Environment Configuration
### Frontend Configuration
For production deployment behind a reverse proxy (Traefik, Nginx, etc.), the frontend should use relative URLs to automatically inherit the protocol (HTTPS) and domain.
1. Copy the production environment template:
```bash
cp frontend/.env.production.example frontend/.env.production
```
2. Set the API URL to use relative path:
```env
# frontend/.env.production
VITE_API_URL=/api
```
This ensures all API calls will use the same domain and protocol as the frontend.
### Backend Configuration
Ensure your backend `.env` file has the correct URLs:
```env
# backend/.env
FRONTEND_URL=https://yourdomain.com
ADMIN_URL=https://yourdomain.com
```
## Docker Compose Production
When using Docker Compose in production:
1. Build with production environment:
```bash
docker-compose -f docker-compose.prod.yml build --build-arg NODE_ENV=production
```
2. The frontend nginx configuration already includes proper proxy settings for:
- `/api` → Backend API
- `/photos` → Protected photo access
- `/thumbnails` → Thumbnail images
- `/uploads` → Public uploads (logos, favicons)
## Traefik Configuration
Example Traefik labels for docker-compose:
```yaml
services:
frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)"
- "traefik.http.routers.picpeak.entrypoints=websecure"
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
```
## Important Notes
1. **No Hardcoded URLs**: The application uses environment variables with relative URL fallbacks, making it production-ready.
2. **HTTPS Only**: When `VITE_API_URL=/api`, all requests will use the same protocol as the page (HTTPS in production).
3. **CORS Configuration**: The backend CORS is configured to accept requests from the URLs specified in `FRONTEND_URL` and `ADMIN_URL`.
4. **Static Assets**: All static assets (photos, thumbnails, uploads) are served through the nginx proxy, inheriting authentication headers.
## Verification
After deployment, verify:
1. Check browser console for any localhost URLs (there should be none)
2. Verify all API calls use HTTPS
3. Check that images load correctly with authentication
4. Test favicon and logo display
## Troubleshooting
If you see console errors about localhost:
1. Ensure `VITE_API_URL=/api` in frontend environment
2. Clear browser cache
3. Rebuild frontend with production environment:
```bash
cd frontend
npm run build
```
If images don't load:
1. Check that nginx proxy locations are configured
2. Verify authentication tokens are being sent
3. Check backend logs for authentication errors
+92 -26
View File
@@ -1,6 +1,6 @@
# Production Deployment Guide
This guide addresses all known production deployment issues and provides solutions.
This comprehensive guide addresses all production deployment scenarios and common issues.
## Pre-Deployment Checklist
@@ -8,43 +8,80 @@ This guide addresses all known production deployment issues and provides solutio
Create a `.env` file with ALL required variables:
```bash
# Required
# CRITICAL - Must change these!
JWT_SECRET=<generate-with-openssl-rand-base64-32>
DB_PASSWORD=<strong-password>
# Application URLs (your actual domain)
ADMIN_URL=https://yourdomain.com
FRONTEND_URL=https://yourdomain.com
BACKEND_URL=https://yourdomain.com
# Database
# Database (PostgreSQL)
DATABASE_CLIENT=pg
DB_HOST=postgres # or external host
DB_PORT=5432
DB_USER=picpeak
DB_NAME=picpeak
# Email (Optional but recommended)
# Email Configuration (required for notifications)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
EMAIL_FROM=noreply@yourdomain.com
SMTP_PASS=your-app-password # Use app-specific password
EMAIL_FROM=PicPeak <noreply@yourdomain.com>
# Umami Analytics (Optional)
UMAMI_URL=https://analytics.yourdomain.com
UMAMI_WEBSITE_ID=your-website-id
UMAMI_HASH_SALT=<generate-random-string>
# Port Configuration
PORT=3001
# Performance Tuning
DB_POOL_MIN=5
DB_POOL_MAX=25
NODE_ENV=production
LOG_LEVEL=info
# Optional: Umami Analytics (configured via Admin UI)
# UMAMI_URL=https://analytics.yourdomain.com
# UMAMI_WEBSITE_ID=your-website-id
```
### 2. Generate Secrets
```bash
# Generate JWT Secret
# Generate JWT Secret (REQUIRED)
openssl rand -base64 32
# Generate Database Password
openssl rand -base64 24
# Generate Umami Hash Salt
openssl rand -hex 32
```
## Frontend Configuration
For production deployment behind a reverse proxy:
### Frontend Environment
```bash
# frontend/.env.production
VITE_API_URL=/api # Uses relative path for reverse proxy
# Optional: Umami fallback (primary config via Admin UI)
# VITE_UMAMI_URL=https://analytics.yourdomain.com
# VITE_UMAMI_WEBSITE_ID=your-website-id
```
This ensures all API calls use the same domain/protocol as the frontend.
### Nginx Proxy Configuration
The frontend nginx configuration already includes proper proxy settings for:
- `/api` → Backend API
- `/photos` → Protected photo access
- `/thumbnails` → Thumbnail images
- `/uploads` → Public uploads (logos, favicons)
All static assets are served through the nginx proxy, inheriting authentication headers.
## Deployment Steps
### 1. Initial Setup
@@ -96,24 +133,31 @@ docker-compose -f docker-compose.prod.yml up -d
docker-compose -f docker-compose.prod.yml logs -f backend
```
### 4. Create Admin User
### 4. Initial Admin Setup
After deployment, create the first admin user:
The admin user is automatically created during database migration:
```bash
# Enter backend container
docker-compose -f docker-compose.prod.yml exec backend sh
# Run migrations (this creates admin user)
docker-compose -f docker-compose.prod.yml exec backend npm run migrate
# Create admin
node scripts/create-admin.js \
--username admin \
--email admin@yourdomain.com \
--password <your-secure-password>
# Admin credentials will be displayed in console and saved to ADMIN_CREDENTIALS.txt
# Example output:
# ========================================
# ✅ Admin user created successfully!
# ========================================
# Username: admin
# Password: SwiftEagle3847!
#
# ⚠️ IMPORTANT: Change password on first login
# ========================================
# Exit container
exit
# Retrieve credentials if needed
docker-compose -f docker-compose.prod.yml exec backend cat ADMIN_CREDENTIALS.txt
```
**Important**: You MUST change the auto-generated password on first login.
### 5. Configure Email (if using database config)
1. Login to admin panel: https://yourdomain.com/admin
@@ -189,6 +233,23 @@ docker-compose -f docker-compose.prod.yml logs backend | grep email
## SSL/HTTPS Setup
### Option 1: Using Traefik (Recommended)
Add these labels to your docker-compose override:
```yaml
services:
frontend:
labels:
- "traefik.enable=true"
- "traefik.http.routers.picpeak.rule=Host(`yourdomain.com`)"
- "traefik.http.routers.picpeak.entrypoints=websecure"
- "traefik.http.routers.picpeak.tls.certresolver=letsencrypt"
- "traefik.http.services.picpeak.loadbalancer.server.port=80"
```
### Option 2: Using Certbot
1. Update `nginx/sites-enabled/default` with your domain
2. Run certbot:
@@ -294,13 +355,18 @@ docker-compose -f docker-compose.prod.yml up -d
- [ ] Strong JWT_SECRET (min 32 chars)
- [ ] Strong database password
- [ ] Admin password changed from auto-generated one
- [ ] SSL/HTTPS enabled
- [ ] Firewall configured (only 80/443 open)
- [ ] Regular security updates
- [ ] Backup encryption
- [ ] Access logs monitored
- [ ] Rate limiting enabled
- [ ] Rate limiting enabled (built-in)
- [ ] File upload restrictions configured
- [ ] Password complexity requirements configured (Admin > Settings)
- [ ] Session timeout configured (default 60 min)
- [ ] Umami analytics configured (if using)
- [ ] SMTP credentials secured with app-specific password
## Support
+14
View File
@@ -159,6 +159,20 @@ Organize and manage your photo galleries with intuitive event management tools.
</details>
## 🗺️ Roadmap
We're constantly improving PicPeak and welcome contributions from our community! If you have ideas for new features or want to help implement existing ones, please open an issue or submit a pull request. Your contributions help make PicPeak better for everyone.
| Feature | Description | Priority | Status |
|---------|-------------|----------|---------|
| **Backup & Restore** | Comprehensive backup system with S3/MinIO support, automated scheduling, and safe restore functionality | High | ✅ Implemented |
| **Gallery Templates** | Additional gallery layouts and themes (masonry, slideshow, story-style) for different event types | Medium | 🔄 Open |
| **Face Recognition** | AI-powered face detection to help guests find their photos and create automatic person-based albums | Low | 🔄 Open |
| **Gallery Feedback** | Allow guests to like, rate, and comment on photos with admin notifications and moderation | Medium | 🔄 Open |
| **Video Support** | Upload and display videos alongside photos in galleries with streaming support | Low | 🔄 Open |
**Status Legend:** ✅ Implemented | 🚧 In Progress | 🔄 Open | 📋 Planned
## 🙏 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.
+33 -16
View File
@@ -3,39 +3,56 @@
# Application
NODE_ENV=production
PORT=3000
PORT=3001
# Security
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long
# Generate with: openssl rand -base64 32
JWT_SECRET=your-very-secure-jwt-secret-at-least-32-characters-long-example123456
# URLs
ADMIN_URL=https://yourdomain.com
FRONTEND_URL=https://yourdomain.com
# URLs (adjust for your domain)
ADMIN_URL=https://photos.example.com
FRONTEND_URL=https://photos.example.com
# Database Configuration
DATABASE_CLIENT=pg
DB_HOST=db
DB_HOST=localhost
DB_PORT=5432
DB_USER=picpeak
DB_PASSWORD=your-secure-database-password
DB_PASSWORD=your-secure-database-password-change-this
DB_NAME=picpeak
# Email Configuration
SMTP_HOST=smtp.example.com
# Email Configuration (Examples for common providers)
# Gmail example:
# SMTP_HOST=smtp.gmail.com
# SMTP_PORT=587
# SMTP_SECURE=false
# SMTP_USER=your-email@gmail.com
# SMTP_PASS=your-app-specific-password
# SendGrid example:
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
SMTP_SECURE=false
SMTP_USER=your-smtp-username
SMTP_PASS=your-smtp-password
EMAIL_FROM=noreply@yourdomain.com
SMTP_USER=apikey
SMTP_PASS=your-sendgrid-api-key
EMAIL_FROM=noreply@example.com
# Storage Paths (Docker)
# Storage Paths
# Docker deployment:
STORAGE_PATH=/app/storage
EVENTS_PATH=/app/storage/events
ARCHIVE_PATH=/app/storage/events/archived
# Analytics (Optional)
UMAMI_URL=https://analytics.yourdomain.com
UMAMI_WEBSITE_ID=your-website-id
# Local development:
# STORAGE_PATH=./storage
# EVENTS_PATH=./storage/events
# ARCHIVE_PATH=./storage/events/archived
# Analytics Backend Configuration (OPTIONAL)
# Used for server-side tracking only
# Primary configuration should be done through Admin UI > Settings > Analytics
# UMAMI_URL=https://analytics.example.com
# UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab
# Logging
LOG_LEVEL=info
+229
View File
@@ -0,0 +1,229 @@
# Enhanced Backup System Test Suite
This directory contains comprehensive tests for the enhanced backup system with S3 support.
## Test Structure
### Unit Tests
- `services/backupService.enhanced.test.js` - Unit tests for the enhanced backup service
- Configuration management
- S3 backup functionality
- Manifest generation
- Error handling and recovery
- Backward compatibility (local and rsync)
- Service lifecycle management
### Integration Tests
- `integration/backup-s3.test.js` - Integration tests for S3 backups
- Real S3/MinIO connection tests
- Full backup process with actual files
- Incremental backup verification
- Manifest storage and retrieval
- Error recovery scenarios
### Manual Integration Test Script
- `../scripts/test-backup-integration.js` - Comprehensive manual testing script
- Can test against MinIO, AWS S3, or any S3-compatible service
- Tests all backup types (S3, local, rsync)
- Performance testing with large files
- Detailed progress reporting
## Running Tests
### Prerequisites
1. **For Unit Tests**: No special setup required, all dependencies are mocked.
2. **For Integration Tests**: Requires a running S3-compatible service (MinIO recommended)
```bash
# Start MinIO using Docker
docker run -d \
-p 9000:9000 \
-p 9001:9001 \
--name minio-test \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio server /data --console-address ":9001"
```
3. **Environment Variables** (for integration tests):
```bash
# Optional - defaults work with local MinIO
export TEST_S3_ENDPOINT=http://localhost:9000
export TEST_S3_ACCESS_KEY=minioadmin
export TEST_S3_SECRET_KEY=minioadmin
# Skip S3 tests if no S3 service available
export SKIP_S3_TESTS=true
```
### Running Unit Tests
```bash
# Run all backup service tests
npm test -- __tests__/services/backupService.enhanced.test.js
# Run specific test suite
npm test -- __tests__/services/backupService.enhanced.test.js -t "S3 Backup Functionality"
# Run with coverage
npm test -- --coverage __tests__/services/backupService.enhanced.test.js
```
### Running Integration Tests
```bash
# Ensure MinIO is running first!
# Run S3 integration tests
npm test -- __tests__/integration/backup-s3.test.js
# Run with verbose output
npm test -- __tests__/integration/backup-s3.test.js --verbose
# Skip S3 tests if needed
SKIP_S3_TESTS=true npm test -- __tests__/integration/backup-s3.test.js
```
### Running Manual Integration Tests
```bash
# Test with local MinIO (default)
node scripts/test-backup-integration.js
# Test with AWS S3
node scripts/test-backup-integration.js \
--endpoint https://s3.amazonaws.com \
--access-key YOUR_ACCESS_KEY \
--secret-key YOUR_SECRET_KEY \
--bucket your-test-bucket
# Test local backup
node scripts/test-backup-integration.js --type local
# Test with cleanup after completion
node scripts/test-backup-integration.js --cleanup
# Verbose output
node scripts/test-backup-integration.js --verbose
```
## Test Coverage
The test suite covers:
### Configuration
- ✅ Database configuration retrieval
- ✅ JSON parsing and error handling
- ✅ Configuration validation
- ✅ Required field validation
### S3 Functionality
- ✅ S3 client initialization
- ✅ Connection testing
- ✅ File upload with progress tracking
- ✅ Large file handling (multipart upload)
- ✅ Metadata and custom headers
- ✅ Error handling and retries
### Backup Process
- ✅ Full backup execution
- ✅ Incremental backup (changed files only)
- ✅ File checksum calculation and comparison
- ✅ Database backup inclusion
- ✅ Archive inclusion toggle
- ✅ File size limits
### Manifest Generation
- ✅ Full manifest generation
- ✅ Incremental manifest with parent reference
- ✅ JSON and YAML format support
- ✅ Manifest validation
- ✅ S3 manifest storage and retrieval
- ✅ Checksum verification
### Error Handling
- ✅ S3 connection failures
- ✅ File read errors
- ✅ Individual file failure recovery
- ✅ Retry logic with exponential backoff
- ✅ Email notifications on failure
- ✅ Concurrent backup prevention
### Backward Compatibility
- ✅ Local directory backup
- ✅ Rsync backup
- ✅ Existing manifest format support
### Service Management
- ✅ Cron job scheduling
- ✅ Service start/stop
- ✅ Manual backup triggering
- ✅ Backup history and status
## Mock Setup
The unit tests use comprehensive mocking:
```javascript
// Database mocking
jest.mock('../../src/database/db');
// S3 client mocking
jest.mock('../../src/services/storage/s3Storage');
// File system mocking
const mockFs = require('mock-fs');
// Cron job mocking
jest.mock('node-cron');
```
## CI/CD Integration
To run tests in CI/CD pipeline:
```yaml
# Example GitHub Actions
- name: Run Unit Tests
run: npm test -- __tests__/services/backupService.enhanced.test.js
- name: Start MinIO
run: |
docker run -d \
-p 9000:9000 \
--name minio-test \
-e MINIO_ROOT_USER=minioadmin \
-e MINIO_ROOT_PASSWORD=minioadmin \
minio/minio server /data
- name: Run Integration Tests
run: npm test -- __tests__/integration/backup-s3.test.js
```
## Debugging Tests
```bash
# Run tests in debug mode
node --inspect-brk ./node_modules/.bin/jest __tests__/services/backupService.enhanced.test.js
# Run single test with console output
npm test -- __tests__/services/backupService.enhanced.test.js -t "should perform S3 backup" --verbose
```
## Performance Considerations
- Integration tests create real files and S3 objects
- Each test run creates a unique S3 bucket to avoid conflicts
- Cleanup is automatic but can be disabled for debugging
- Large file tests (10MB+) are included but can be slow
## Adding New Tests
When adding new backup features:
1. Add unit tests to `backupService.enhanced.test.js`
2. Add integration tests to `backup-s3.test.js` if S3-specific
3. Update manual test script for comprehensive testing
4. Ensure mocks are properly configured
5. Document any new environment requirements
@@ -0,0 +1,506 @@
const { describe, it, expect, jest, beforeAll, afterAll, beforeEach, afterEach } = require('@jest/globals');
const { S3Client, CreateBucketCommand, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = require('@aws-sdk/client-s3');
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
// Load services
const backupService = require('../../src/services/backupService');
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
const { db, initialize: initDb } = require('../../src/database/db');
const logger = require('../../src/utils/logger');
// Test configuration
const TEST_CONFIG = {
endpoint: process.env.TEST_S3_ENDPOINT || 'http://localhost:9000',
accessKeyId: process.env.TEST_S3_ACCESS_KEY || 'minioadmin',
secretAccessKey: process.env.TEST_S3_SECRET_KEY || 'minioadmin',
bucket: 'test-backup-bucket-' + Date.now(),
region: 'us-east-1'
};
describe('S3 Backup Integration Tests', () => {
let s3Client;
let testStoragePath;
let originalEnv;
beforeAll(async () => {
// Skip if no S3 endpoint configured
if (process.env.SKIP_S3_TESTS === 'true') {
console.log('Skipping S3 integration tests (SKIP_S3_TESTS=true)');
return;
}
// Save original environment
originalEnv = { ...process.env };
// Initialize S3 client for test setup
s3Client = new S3Client({
endpoint: TEST_CONFIG.endpoint,
region: TEST_CONFIG.region,
credentials: {
accessKeyId: TEST_CONFIG.accessKeyId,
secretAccessKey: TEST_CONFIG.secretAccessKey
},
forcePathStyle: true
});
// Create test bucket
try {
await s3Client.send(new CreateBucketCommand({ Bucket: TEST_CONFIG.bucket }));
console.log(`Created test bucket: ${TEST_CONFIG.bucket}`);
} catch (error) {
if (error.name !== 'BucketAlreadyOwnedByYou') {
console.error('Failed to create test bucket:', error);
throw error;
}
}
// Initialize database
await initDb();
await db.migrate.latest();
// Create test storage directory
testStoragePath = path.join(__dirname, '../fixtures/test-storage');
await fs.mkdir(testStoragePath, { recursive: true });
process.env.STORAGE_PATH = testStoragePath;
// Set up test data
await setupTestData();
// Mock logger to reduce noise
logger.info = jest.fn();
logger.debug = jest.fn();
logger.warn = jest.fn();
logger.error = jest.fn();
});
afterAll(async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
try {
// Clean up S3 bucket
await cleanupS3Bucket();
await s3Client.send(new DeleteBucketCommand({ Bucket: TEST_CONFIG.bucket }));
console.log(`Deleted test bucket: ${TEST_CONFIG.bucket}`);
} catch (error) {
console.error('Failed to cleanup S3 bucket:', error);
}
// Clean up test storage
await fs.rm(testStoragePath, { recursive: true, force: true });
// Restore environment
process.env = originalEnv;
// Close database
await db.destroy();
});
beforeEach(async () => {
if (process.env.SKIP_S3_TESTS === 'true') {
return;
}
// Clean backup tables
await db('backup_runs').del();
await db('backup_file_states').del();
await db('database_backup_runs').del();
// Configure S3 backup settings
await configureS3Backup();
});
afterEach(async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Clean up S3 objects created during test
await cleanupS3Bucket();
});
describe('S3 Connection and Configuration', () => {
it('should successfully connect to S3-compatible storage', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
const s3Adapter = new S3StorageAdapter({
...TEST_CONFIG,
bucket: TEST_CONFIG.bucket,
forcePathStyle: true,
sslEnabled: false
});
const connected = await s3Adapter.testConnection();
expect(connected).toBe(true);
});
it('should validate S3 configuration before backup', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Remove required configuration
await db('app_settings')
.where('setting_key', 'backup_s3_secret_key')
.del();
await backupService.runBackup();
const lastRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
expect(lastRun.status).toBe('failed');
expect(lastRun.error_message).toContain('S3 backup configuration incomplete');
});
});
describe('Full S3 Backup Process', () => {
it('should perform complete S3 backup with all file types', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Run backup
await backupService.runBackup();
// Verify backup run completed
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
expect(backupRun.status).toBe('completed');
expect(backupRun.files_backed_up).toBeGreaterThan(0);
expect(backupRun.total_size_bytes).toBeGreaterThan(0);
// Verify files in S3
const s3Objects = await listS3Objects();
expect(s3Objects.length).toBeGreaterThan(0);
// Check for expected file types
const hasPhotos = s3Objects.some(obj => obj.Key.includes('events/active'));
const hasThumbnails = s3Objects.some(obj => obj.Key.includes('thumbnails'));
const hasManifest = s3Objects.some(obj => obj.Key.includes('backup-manifest'));
const hasSummary = s3Objects.some(obj => obj.Key.includes('backup-summary.json'));
expect(hasPhotos).toBe(true);
expect(hasThumbnails).toBe(true);
expect(hasManifest).toBe(true);
expect(hasSummary).toBe(true);
});
it('should handle large file uploads with multipart', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Create a large test file (15MB)
const largeFilePath = path.join(testStoragePath, 'events/active/large-photo.jpg');
const largeFileSize = 15 * 1024 * 1024; // 15MB
const largeFileContent = Buffer.alloc(largeFileSize, 'x');
await fs.writeFile(largeFilePath, largeFileContent);
// Run backup
await backupService.runBackup();
// Verify large file was uploaded
const s3Objects = await listS3Objects();
const largeFileUploaded = s3Objects.some(obj =>
obj.Key.includes('large-photo.jpg') && obj.Size === largeFileSize
);
expect(largeFileUploaded).toBe(true);
});
it('should include database backup when available', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Create a mock database backup
const dbBackupPath = path.join(testStoragePath, 'backups/db-backup.sql');
await fs.mkdir(path.dirname(dbBackupPath), { recursive: true });
await fs.writeFile(dbBackupPath, 'CREATE TABLE test (id INT);');
// Record database backup
await db('database_backup_runs').insert({
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
backup_type: 'sqlite',
file_path: dbBackupPath,
file_size_bytes: 100,
checksum: 'test123',
statistics: JSON.stringify({ tables: {} }),
table_checksums: JSON.stringify({})
});
// Configure to include database
await db('app_settings')
.where('setting_key', 'backup_include_database')
.update({ setting_value: 'true' });
// Run backup
await backupService.runBackup();
// Verify database backup in S3
const s3Objects = await listS3Objects();
const hasDbBackup = s3Objects.some(obj => obj.Key.includes('database/db-backup.sql'));
expect(hasDbBackup).toBe(true);
});
});
describe('Incremental Backup', () => {
it('should only upload changed files in incremental backup', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// First backup - full
await backupService.runBackup();
const firstRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
const firstObjectCount = (await listS3Objects()).length;
// Wait a moment to ensure different timestamps
await new Promise(resolve => setTimeout(resolve, 100));
// Modify one file
const modifiedFile = path.join(testStoragePath, 'events/active/event1/photo1.jpg');
await fs.writeFile(modifiedFile, 'modified content');
// Second backup - incremental
await backupService.runBackup();
const secondRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
expect(secondRun.id).not.toBe(firstRun.id);
expect(secondRun.files_backed_up).toBe(1); // Only modified file
// Check manifest indicates incremental
if (secondRun.manifest_path) {
const manifest = await backupService.getBackupManifest(secondRun.id);
expect(manifest.manifest.incremental).toBeDefined();
expect(manifest.manifest.incremental.modified_files_count).toBe(1);
}
});
it('should track file states across backups', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
await backupService.runBackup();
// Check file states are recorded
const fileStates = await db('backup_file_states').select('*');
expect(fileStates.length).toBeGreaterThan(0);
// Verify checksums are stored
const hasChecksums = fileStates.every(state => state.checksum !== null);
expect(hasChecksums).toBe(true);
});
});
describe('S3 Manifest Storage', () => {
it('should upload manifest to S3 and retrieve it', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Configure YAML manifest format
await db('app_settings')
.where('setting_key', 'backup_manifest_format')
.update({ setting_value: '"yaml"' });
await backupService.runBackup();
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
expect(backupRun.manifest_path).toMatch(/^s3:\/\//);
// Retrieve manifest
const { manifest, summary } = await backupService.getBackupManifest(backupRun.id);
expect(manifest).toBeDefined();
expect(manifest.backup.id).toBeDefined();
expect(summary).toContain('BACKUP MANIFEST SUMMARY');
});
it('should validate manifest integrity', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
await backupService.runBackup();
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
const validationResult = await backupService.validateBackupManifest(backupRun.manifest_path);
expect(validationResult.valid).toBe(true);
expect(validationResult.manifest).toBeDefined();
});
});
describe('Error Recovery', () => {
it('should handle S3 connection failures gracefully', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Configure with invalid endpoint
await db('app_settings')
.where('setting_key', 'backup_s3_endpoint')
.update({ setting_value: '"http://invalid-endpoint:9999"' });
await backupService.runBackup();
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
expect(backupRun.status).toBe('failed');
expect(backupRun.error_message).toBeDefined();
});
it('should continue backup despite individual file failures', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Create a file that will be deleted during backup
const tempFile = path.join(testStoragePath, 'events/active/temp.jpg');
await fs.writeFile(tempFile, 'temporary');
// Mock file deletion during backup
const originalUpload = S3StorageAdapter.prototype.upload;
let callCount = 0;
S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) {
callCount++;
if (callCount === 2) {
// Delete the temp file to cause an error
await fs.unlink(tempFile).catch(() => {});
}
return originalUpload.call(this, localPath, s3Key, options);
});
await backupService.runBackup();
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
// Should complete despite one file error
expect(backupRun.status).toBe('completed');
expect(backupRun.files_backed_up).toBeGreaterThan(0);
// Restore original method
S3StorageAdapter.prototype.upload = originalUpload;
});
it('should retry failed uploads with exponential backoff', async () => {
if (process.env.SKIP_S3_TESTS === 'true') return;
// Mock S3 upload to fail twice then succeed
const originalUpload = S3StorageAdapter.prototype.upload;
let attemptCount = 0;
S3StorageAdapter.prototype.upload = jest.fn(async function(localPath, s3Key, options) {
attemptCount++;
if (attemptCount <= 2) {
const error = new Error('Network timeout');
error.code = 'ETIMEDOUT';
throw error;
}
return originalUpload.call(this, localPath, s3Key, options);
});
await backupService.runBackup();
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
// Should succeed after retries
expect(backupRun.status).toBe('completed');
expect(attemptCount).toBeGreaterThan(2);
// Restore original method
S3StorageAdapter.prototype.upload = originalUpload;
});
});
// Helper functions
async function setupTestData() {
// Create test directory structure
const dirs = [
'events/active/event1',
'events/active/event2',
'events/archived',
'thumbnails',
'uploads'
];
for (const dir of dirs) {
await fs.mkdir(path.join(testStoragePath, dir), { recursive: true });
}
// Create test files
const files = [
{ path: 'events/active/event1/photo1.jpg', content: 'photo1 content' },
{ path: 'events/active/event1/photo2.jpg', content: 'photo2 content' },
{ path: 'events/active/event2/photo3.jpg', content: 'photo3 content' },
{ path: 'events/archived/old-event.zip', content: 'archived content' },
{ path: 'thumbnails/thumb1.jpg', content: 'thumbnail content' },
{ path: 'uploads/logo.png', content: 'logo content' }
];
for (const file of files) {
await fs.writeFile(
path.join(testStoragePath, file.path),
file.content
);
}
}
async function configureS3Backup() {
const settings = [
{ setting_key: 'backup_enabled', setting_value: 'true' },
{ setting_key: 'backup_destination_type', setting_value: '"s3"' },
{ setting_key: 'backup_s3_bucket', setting_value: `"${TEST_CONFIG.bucket}"` },
{ setting_key: 'backup_s3_region', setting_value: `"${TEST_CONFIG.region}"` },
{ setting_key: 'backup_s3_endpoint', setting_value: `"${TEST_CONFIG.endpoint}"` },
{ setting_key: 'backup_s3_access_key', setting_value: `"${TEST_CONFIG.accessKeyId}"` },
{ setting_key: 'backup_s3_secret_key', setting_value: `"${TEST_CONFIG.secretAccessKey}"` },
{ setting_key: 'backup_s3_force_path_style', setting_value: 'true' },
{ setting_key: 'backup_s3_ssl_enabled', setting_value: 'false' },
{ setting_key: 'backup_include_archived', setting_value: 'true' },
{ setting_key: 'backup_incremental', setting_value: 'true' },
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
];
for (const setting of settings) {
await db('app_settings')
.insert({
setting_type: 'backup',
...setting,
created_at: new Date(),
updated_at: new Date()
})
.onConflict(['setting_type', 'setting_key'])
.merge();
}
}
async function listS3Objects() {
const response = await s3Client.send(new ListObjectsV2Command({
Bucket: TEST_CONFIG.bucket
}));
return response.Contents || [];
}
async function cleanupS3Bucket() {
try {
const objects = await listS3Objects();
if (objects.length > 0) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: TEST_CONFIG.bucket,
Delete: {
Objects: objects.map(obj => ({ Key: obj.Key }))
}
}));
}
} catch (error) {
console.error('Failed to cleanup S3 objects:', error);
}
}
});
@@ -0,0 +1,751 @@
const { describe, it, expect, jest, beforeEach, afterEach } = require('@jest/globals');
const mockFs = require('mock-fs');
const path = require('path');
const crypto = require('crypto');
const { EventEmitter } = require('events');
// Mock dependencies before requiring the module
jest.mock('../../src/database/db');
jest.mock('../../src/utils/logger');
jest.mock('../../src/services/emailProcessor');
jest.mock('node-cron');
jest.mock('../../src/services/backupManifest');
jest.mock('../../src/services/storage/s3Storage');
const backupService = require('../../src/services/backupService');
const { db } = require('../../src/database/db');
const logger = require('../../src/utils/logger');
const { queueEmail } = require('../../src/services/emailProcessor');
const cron = require('node-cron');
const backupManifest = require('../../src/services/backupManifest');
const S3StorageAdapter = require('../../src/services/storage/s3Storage');
describe('Enhanced Backup Service Tests', () => {
let mockDb;
let mockS3Client;
let mockCronJob;
beforeEach(() => {
// Reset all mocks
jest.clearAllMocks();
// Mock database
mockDb = {
select: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
first: jest.fn(),
insert: jest.fn(),
update: jest.fn(),
delete: jest.fn()
};
db.mockReturnValue(mockDb);
// Mock cron job
mockCronJob = {
stop: jest.fn()
};
cron.schedule.mockReturnValue(mockCronJob);
// Mock S3 client
mockS3Client = {
testConnection: jest.fn().mockResolvedValue(true),
upload: jest.fn().mockResolvedValue({ Location: 's3://bucket/key' }),
uploadStream: jest.fn().mockResolvedValue({ Location: 's3://bucket/key' }),
download: jest.fn().mockResolvedValue(),
exists: jest.fn().mockResolvedValue(false),
delete: jest.fn().mockResolvedValue(),
list: jest.fn().mockResolvedValue({ Contents: [] })
};
S3StorageAdapter.mockImplementation(() => mockS3Client);
// Mock backup manifest
backupManifest.generateManifest = jest.fn().mockResolvedValue({
backup: { id: 'test-backup-123' },
version: '2.0'
});
backupManifest.saveManifest = jest.fn().mockResolvedValue('/path/to/manifest.json');
backupManifest.loadManifest = jest.fn().mockResolvedValue({});
backupManifest.validateManifest = jest.fn();
backupManifest.generateSummaryReport = jest.fn().mockReturnValue('Summary report');
// Mock logger
logger.info = jest.fn();
logger.error = jest.fn();
logger.warn = jest.fn();
logger.debug = jest.fn();
});
afterEach(() => {
mockFs.restore();
});
describe('getBackupConfig', () => {
it('should retrieve and parse backup configuration from database', async () => {
const mockSettings = [
{ setting_key: 'backup_enabled', setting_value: 'true' },
{ setting_key: 'backup_destination_type', setting_value: '"s3"' },
{ setting_key: 'backup_s3_bucket', setting_value: '"test-bucket"' },
{ setting_key: 'backup_retention_days', setting_value: '30' }
];
mockDb.select.mockResolvedValue(mockSettings);
const config = await backupService.getBackupConfig();
expect(config).toEqual({
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_retention_days: 30
});
expect(db).toHaveBeenCalledWith('app_settings');
expect(mockDb.where).toHaveBeenCalledWith('setting_type', 'backup');
});
it('should handle JSON parse errors gracefully', async () => {
const mockSettings = [
{ setting_key: 'backup_enabled', setting_value: 'invalid-json' }
];
mockDb.select.mockResolvedValue(mockSettings);
const config = await backupService.getBackupConfig();
expect(config).toEqual({
backup_enabled: 'invalid-json'
});
});
it('should return null on database error', async () => {
mockDb.select.mockRejectedValue(new Error('Database error'));
const config = await backupService.getBackupConfig();
expect(config).toBeNull();
expect(logger.error).toHaveBeenCalled();
});
});
describe('S3 Backup Functionality', () => {
beforeEach(() => {
// Mock file system
mockFs({
'/storage/events/active/event1': {
'photo1.jpg': Buffer.from('photo1 content'),
'photo2.jpg': Buffer.from('photo2 content')
},
'/storage/events/archived/event2.zip': Buffer.from('archived content'),
'/storage/thumbnails': {
'thumb1.jpg': Buffer.from('thumb1 content')
},
'/storage/uploads': {
'logo.png': Buffer.from('logo content')
}
});
process.env.STORAGE_PATH = '/storage';
});
it('should perform S3 backup with correct configuration', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_region: 'us-east-1',
backup_s3_endpoint: 'https://s3.amazonaws.com',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret',
backup_include_archived: true,
backup_max_file_size_mb: 100
};
mockDb.select.mockResolvedValue([]);
mockDb.where.mockReturnThis();
mockDb.first.mockResolvedValue(null);
mockDb.insert.mockResolvedValue([1]);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
type: 'sqlite',
backupFile: null,
hasChanged: true
});
await backupService.runBackup();
expect(S3StorageAdapter).toHaveBeenCalledWith({
bucket: 'test-bucket',
region: 'us-east-1',
endpoint: 'https://s3.amazonaws.com',
accessKeyId: 'test-key',
secretAccessKey: 'test-secret',
forcePathStyle: false,
sslEnabled: true,
maxRetries: 3,
retryDelay: 1000
});
expect(mockS3Client.testConnection).toHaveBeenCalled();
expect(mockS3Client.upload).toHaveBeenCalled();
});
it('should handle S3 upload failures gracefully', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockS3Client.testConnection.mockRejectedValue(new Error('Connection failed'));
await backupService.runBackup();
expect(logger.error).toHaveBeenCalledWith('S3 backup failed:', expect.any(Error));
expect(mockDb.update).toHaveBeenCalledWith(expect.objectContaining({
status: 'failed',
error_message: expect.stringContaining('Connection failed')
}));
});
it('should skip unchanged files in incremental backup', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret',
backup_incremental: true
};
// Mock existing file state
mockDb.first.mockImplementation((query) => {
if (query === undefined) {
return Promise.resolve({
file_path: 'events/active/event1/photo1.jpg',
checksum: crypto.createHash('sha256').update('photo1 content').digest('hex')
});
}
return Promise.resolve(null);
});
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
await backupService.runBackup();
// Should skip unchanged file
const uploadCalls = mockS3Client.upload.mock.calls;
const photo1Uploaded = uploadCalls.some(call =>
call[1].includes('photo1.jpg')
);
expect(photo1Uploaded).toBe(false);
});
it('should include database backup when configured', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret',
backup_include_database: true
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
jest.spyOn(backupService, 'getDatabaseBackupInfo').mockResolvedValue({
type: 'sqlite',
backupFile: '/backup/db-backup.sql',
size: 1024000,
checksum: 'abc123',
hasChanged: false
});
// Mock database backup file
mockFs({
'/storage/events/active': {},
'/backup/db-backup.sql': Buffer.from('database backup content')
});
await backupService.runBackup();
// Verify database backup was uploaded
const uploadCalls = mockS3Client.upload.mock.calls;
const dbBackupUploaded = uploadCalls.some(call =>
call[1].includes('database/db-backup.sql')
);
expect(dbBackupUploaded).toBe(true);
});
it('should validate required S3 configuration', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket'
// Missing access key and secret key
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
await backupService.runBackup();
expect(logger.error).toHaveBeenCalledWith(
'S3 backup failed:',
expect.objectContaining({
message: expect.stringContaining('S3 backup configuration incomplete')
})
);
});
});
describe('Manifest Generation', () => {
it('should generate and save manifest for successful backup', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 'local',
backup_destination_path: '/backup',
backup_manifest_format: 'json'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({
'/storage/events/active/event1': {
'photo1.jpg': Buffer.from('photo1 content')
},
'/backup': {}
});
await backupService.runBackup();
expect(backupManifest.generateManifest).toHaveBeenCalledWith(
expect.objectContaining({
backupType: 'full',
backupPath: '/backup',
format: 'json'
})
);
expect(backupManifest.saveManifest).toHaveBeenCalled();
});
it('should generate incremental manifest when parent exists', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 'local',
backup_destination_path: '/backup'
};
const lastBackup = {
id: 1,
manifest_path: '/backup/manifests/previous.json',
manifest_id: 'previous-backup-123'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([2]);
mockDb.first.mockImplementation(() => Promise.resolve(lastBackup));
mockDb.orderBy.mockReturnThis();
mockDb.where.mockReturnThis();
mockDb.whereNot = jest.fn().mockReturnThis();
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({
'/storage/events/active': {},
'/backup': {}
});
await backupService.runBackup();
expect(backupManifest.loadManifest).toHaveBeenCalledWith('/backup/manifests/previous.json');
expect(backupManifest.generateIncrementalManifest).toHaveBeenCalled();
});
it('should upload manifest to S3 for S3 backups', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret',
backup_manifest_format: 'yaml'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
const manifest = {
backup: { id: 'backup-123' },
version: '2.0'
};
backupManifest.generateManifest.mockResolvedValue(manifest);
mockFs({
'/storage/events/active': {},
'/storage/temp': {}
});
await backupService.runBackup();
// Verify manifest was uploaded to S3
const uploadCalls = mockS3Client.upload.mock.calls;
const manifestUploaded = uploadCalls.some(call =>
call[1].includes('manifests/backup-manifest-backup-123.yaml')
);
expect(manifestUploaded).toBe(true);
});
});
describe('Backward Compatibility', () => {
it('should support local backup destination', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 'local',
backup_destination_path: '/backup/local'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({
'/storage/events/active/event1': {
'photo1.jpg': Buffer.from('photo1 content')
},
'/backup/local': {}
});
await backupService.runBackup();
// Verify files were copied to local destination
const fs = require('fs');
const destPath = '/backup/local/events/active/event1/photo1.jpg';
expect(fs.existsSync(destPath)).toBe(true);
});
it('should support rsync backup destination', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 'rsync',
backup_rsync_host: 'backup.example.com',
backup_rsync_user: 'backup',
backup_rsync_path: '/remote/backup'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
// Mock exec for rsync
const { exec } = require('child_process');
const mockExec = jest.fn((cmd, callback) => {
callback(null, { stdout: 'Number of files transferred: 1\nTotal file size: 1024 bytes' });
});
exec.mockImplementation(mockExec);
mockFs({
'/storage/events/active': {}
});
await backupService.runBackup();
expect(mockExec).toHaveBeenCalledWith(
expect.stringContaining('rsync'),
expect.any(Function)
);
});
});
describe('Error Handling and Recovery', () => {
it('should handle file read errors gracefully', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_s3_access_key: 'test-key',
backup_s3_secret_key: 'test-secret'
};
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.first.mockResolvedValue(null);
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
// Mock file that throws error on read
const fs = require('fs');
const originalCreateReadStream = fs.createReadStream;
fs.createReadStream = jest.fn((path) => {
if (path.includes('error.jpg')) {
const stream = new EventEmitter();
process.nextTick(() => stream.emit('error', new Error('File read error')));
return stream;
}
return originalCreateReadStream(path);
});
mockFs({
'/storage/events/active': {
'error.jpg': Buffer.from('content'),
'good.jpg': Buffer.from('content')
}
});
await backupService.runBackup();
// Should continue with other files despite error
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining('Failed to backup file'),
expect.any(Error)
);
fs.createReadStream = originalCreateReadStream;
});
it('should send failure email on backup error', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 's3',
backup_s3_bucket: 'test-bucket',
backup_email_on_failure: true
};
const admins = [
{ email: 'admin1@example.com', is_active: true },
{ email: 'admin2@example.com', is_active: true }
];
mockDb.select.mockResolvedValue([]);
mockDb.insert.mockResolvedValue([1]);
mockDb.where.mockReturnThis();
jest.spyOn(backupService, 'getBackupConfig')
.mockResolvedValueOnce(config)
.mockResolvedValueOnce(config);
// Force an error
jest.spyOn(backupService, 'getFilesToBackup').mockRejectedValue(new Error('Storage error'));
// Mock admin users query
db.mockImplementation((table) => {
if (table === 'admin_users') {
return {
where: jest.fn().mockResolvedValue(admins)
};
}
return mockDb;
});
await backupService.runBackup();
expect(queueEmail).toHaveBeenCalledTimes(2);
expect(queueEmail).toHaveBeenCalledWith(
null,
'admin1@example.com',
'backup_failed',
expect.objectContaining({
error_message: 'Storage error'
})
);
});
it('should handle concurrent backup attempts', async () => {
const config = {
backup_enabled: true,
backup_destination_type: 'local',
backup_destination_path: '/backup'
};
jest.spyOn(backupService, 'getBackupConfig').mockResolvedValue(config);
mockFs({
'/storage/events/active': {},
'/backup': {}
});
// Start two backups concurrently
const backup1 = backupService.runBackup();
const backup2 = backupService.runBackup();
await Promise.all([backup1, backup2]);
// Second backup should be skipped
expect(logger.warn).toHaveBeenCalledWith('Backup already running, skipping');
});
});
describe('Service Lifecycle', () => {
it('should start backup service with cron schedule', async () => {
const config = {
backup_enabled: true,
backup_schedule: '0 3 * * *' // 3 AM daily
};
mockDb.select.mockResolvedValue(
Object.entries(config).map(([key, value]) => ({
setting_key: key,
setting_value: value.toString()
}))
);
await backupService.startBackupService();
expect(cron.schedule).toHaveBeenCalledWith('0 3 * * *', expect.any(Function));
expect(logger.info).toHaveBeenCalledWith('Backup service started with schedule: 0 3 * * *');
});
it('should stop existing job when restarting service', async () => {
const config = {
backup_enabled: true,
backup_schedule: '0 2 * * *'
};
mockDb.select.mockResolvedValue(
Object.entries(config).map(([key, value]) => ({
setting_key: key,
setting_value: value.toString()
}))
);
// Start service twice
await backupService.startBackupService();
await backupService.startBackupService();
expect(mockCronJob.stop).toHaveBeenCalled();
});
it('should not start service when backup is disabled', async () => {
const config = {
backup_enabled: false
};
mockDb.select.mockResolvedValue([
{ setting_key: 'backup_enabled', setting_value: 'false' }
]);
await backupService.startBackupService();
expect(cron.schedule).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith('Backup service is disabled');
});
});
describe('Backup Status and History', () => {
it('should return backup status with recent runs', async () => {
const recentRuns = [
{
id: 1,
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
files_backed_up: 100,
total_size_bytes: 1024000,
manifest_path: '/backup/manifest.json'
}
];
mockDb.limit.mockResolvedValue(recentRuns);
backupManifest.validateManifest.mockImplementation(() => true);
const status = await backupService.getBackupStatus();
expect(status).toEqual({
isRunning: false,
isHealthy: true,
lastRun: expect.objectContaining({
...recentRuns[0],
manifestValid: true
}),
recentRuns: recentRuns,
nextScheduledRun: expect.any(String)
});
});
it('should clean up old backup runs', async () => {
mockDb.delete.mockResolvedValue(5);
await backupService.cleanupOldBackupRuns(30);
expect(mockDb.where).toHaveBeenCalledWith('started_at', '<', expect.any(Date));
expect(mockDb.delete).toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith('Cleaned up 5 old backup runs');
});
});
describe('getBackupManifest', () => {
it('should retrieve manifest from local filesystem', async () => {
const backupRun = {
id: 1,
manifest_path: '/backup/manifests/backup-123.json'
};
mockDb.first.mockResolvedValue(backupRun);
const manifest = { backup: { id: 'backup-123' } };
backupManifest.loadManifest.mockResolvedValue(manifest);
backupManifest.generateSummaryReport.mockReturnValue('Summary');
const result = await backupService.getBackupManifest(1);
expect(result).toEqual({
manifest: manifest,
summary: 'Summary'
});
});
it('should retrieve manifest from S3', async () => {
const backupRun = {
id: 1,
manifest_path: 's3://test-bucket/backups/manifests/backup-123.json'
};
mockDb.first.mockResolvedValue(backupRun);
mockDb.select.mockResolvedValue([
{ setting_key: 'backup_s3_access_key', setting_value: '"test-key"' },
{ setting_key: 'backup_s3_secret_key', setting_value: '"test-secret"' }
]);
const manifest = { backup: { id: 'backup-123' } };
backupManifest.loadManifest.mockResolvedValue(manifest);
await backupService.getBackupManifest(1);
expect(S3StorageAdapter).toHaveBeenCalled();
expect(mockS3Client.download).toHaveBeenCalledWith(
'backups/manifests/backup-123.json',
expect.any(String)
);
});
});
});
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
+4 -4
View File
@@ -40,10 +40,10 @@ const config = {
keepAliveInitialDelayMillis: 0
},
pool: {
min: 2,
max: 10,
acquireTimeoutMillis: 30000,
createTimeoutMillis: 30000,
min: 5,
max: 25,
acquireTimeoutMillis: 60000,
createTimeoutMillis: 60000,
idleTimeoutMillis: 30000,
reapIntervalMillis: 1000,
createRetryIntervalMillis: 200,
@@ -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,243 @@
const { db } = require('../src/database/db');
async function up() {
console.log('Adding backup service tables and settings...');
// Create backup_runs table to track backup history
const hasBackupRunsTable = await db.schema.hasTable('backup_runs');
if (!hasBackupRunsTable) {
await db.schema.createTable('backup_runs', (table) => {
table.increments('id').primary();
table.datetime('started_at').notNullable();
table.datetime('completed_at');
table.string('status').defaultTo('running'); // running, completed, failed
table.string('backup_type'); // full, incremental
table.integer('files_backed_up').defaultTo(0);
table.bigInteger('total_size_bytes').defaultTo(0);
table.integer('duration_seconds');
table.text('error_message');
table.json('statistics'); // Detailed stats about the backup
table.json('file_checksums'); // Store checksums for change detection
});
}
// Create backup_file_states table to track individual file states
const hasBackupFileStatesTable = await db.schema.hasTable('backup_file_states');
if (!hasBackupFileStatesTable) {
await db.schema.createTable('backup_file_states', (table) => {
table.increments('id').primary();
table.string('file_path').notNullable();
table.string('checksum').notNullable();
table.bigInteger('size_bytes');
table.datetime('last_modified');
table.datetime('last_backed_up');
table.boolean('is_archived').defaultTo(false);
table.index(['file_path'], 'idx_backup_file_path');
table.index(['checksum'], 'idx_backup_checksum');
});
}
// Add backup-related settings to app_settings
const backupSettings = [
{
setting_key: 'backup_enabled',
setting_value: JSON.stringify(false),
setting_type: 'backup'
},
{
setting_key: 'backup_schedule',
setting_value: JSON.stringify('0 2 * * *'), // Default: 2 AM daily
setting_type: 'backup'
},
{
setting_key: 'backup_destination_type',
setting_value: JSON.stringify('local'), // local, rsync, s3
setting_type: 'backup'
},
{
setting_key: 'backup_destination_path',
setting_value: JSON.stringify('/backup/picpeak'),
setting_type: 'backup'
},
{
setting_key: 'backup_rsync_host',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_rsync_user',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_rsync_path',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_rsync_ssh_key',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_s3_endpoint',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_s3_bucket',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_s3_access_key',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_s3_secret_key',
setting_value: JSON.stringify(''),
setting_type: 'backup'
},
{
setting_key: 'backup_s3_region',
setting_value: JSON.stringify('us-east-1'),
setting_type: 'backup'
},
{
setting_key: 'backup_retention_days',
setting_value: JSON.stringify(30),
setting_type: 'backup'
},
{
setting_key: 'backup_include_archived',
setting_value: JSON.stringify(true),
setting_type: 'backup'
},
{
setting_key: 'backup_compression',
setting_value: JSON.stringify(true),
setting_type: 'backup'
},
{
setting_key: 'backup_email_on_failure',
setting_value: JSON.stringify(true),
setting_type: 'backup'
},
{
setting_key: 'backup_email_on_success',
setting_value: JSON.stringify(false),
setting_type: 'backup'
},
{
setting_key: 'backup_max_file_size_mb',
setting_value: JSON.stringify(5000), // Skip files larger than 5GB
setting_type: 'backup'
},
{
setting_key: 'backup_exclude_patterns',
setting_value: JSON.stringify(['*.tmp', '.DS_Store', 'Thumbs.db']),
setting_type: 'backup'
}
];
// Insert backup settings if they don't exist
for (const setting of backupSettings) {
const exists = await db('app_settings')
.where('setting_key', setting.setting_key)
.first();
if (!exists) {
await db('app_settings').insert(setting);
}
}
// Add backup-related email templates
const backupEmailTemplates = [
{
template_key: 'backup_failed',
subject_en: 'Backup Failed - Immediate Attention Required',
subject_de: 'Backup fehlgeschlagen - Sofortige Aufmerksamkeit erforderlich',
body_html_en: `<h2>Backup Failed</h2>
<p>The scheduled backup has failed and requires immediate attention.</p>
<p><strong>Error Details:</strong></p>
<ul>
<li>Start Time: {{start_time}}</li>
<li>Backup Type: {{backup_type}}</li>
<li>Error: {{error_message}}</li>
</ul>
<p>Please check the system logs for more details and resolve the issue as soon as possible.</p>`,
body_html_de: `<h2>Backup fehlgeschlagen</h2>
<p>Das geplante Backup ist fehlgeschlagen und erfordert sofortige Aufmerksamkeit.</p>
<p><strong>Fehlerdetails:</strong></p>
<ul>
<li>Startzeit: {{start_time}}</li>
<li>Backup-Typ: {{backup_type}}</li>
<li>Fehler: {{error_message}}</li>
</ul>
<p>Bitte überprüfen Sie die Systemprotokolle für weitere Details und beheben Sie das Problem so schnell wie möglich.</p>`,
body_text_en: 'Backup Failed\n\nThe scheduled backup has failed and requires immediate attention.\n\nStart Time: {{start_time}}\nBackup Type: {{backup_type}}\nError: {{error_message}}\n\nPlease check the system logs for more details.',
body_text_de: 'Backup fehlgeschlagen\n\nDas geplante Backup ist fehlgeschlagen und erfordert sofortige Aufmerksamkeit.\n\nStartzeit: {{start_time}}\nBackup-Typ: {{backup_type}}\nFehler: {{error_message}}\n\nBitte überprüfen Sie die Systemprotokolle für weitere Details.',
variables: JSON.stringify(['start_time', 'backup_type', 'error_message'])
},
{
template_key: 'backup_completed',
subject_en: 'Backup Completed Successfully',
subject_de: 'Backup erfolgreich abgeschlossen',
body_html_en: `<h2>Backup Completed</h2>
<p>The scheduled backup has been completed successfully.</p>
<p><strong>Backup Summary:</strong></p>
<ul>
<li>Start Time: {{start_time}}</li>
<li>Duration: {{duration}}</li>
<li>Files Backed Up: {{files_count}}</li>
<li>Total Size: {{total_size}}</li>
<li>Backup Type: {{backup_type}}</li>
</ul>`,
body_html_de: `<h2>Backup abgeschlossen</h2>
<p>Das geplante Backup wurde erfolgreich abgeschlossen.</p>
<p><strong>Backup-Zusammenfassung:</strong></p>
<ul>
<li>Startzeit: {{start_time}}</li>
<li>Dauer: {{duration}}</li>
<li>Gesicherte Dateien: {{files_count}}</li>
<li>Gesamtgröße: {{total_size}}</li>
<li>Backup-Typ: {{backup_type}}</li>
</ul>`,
body_text_en: 'Backup Completed\n\nThe scheduled backup has been completed successfully.\n\nStart Time: {{start_time}}\nDuration: {{duration}}\nFiles Backed Up: {{files_count}}\nTotal Size: {{total_size}}\nBackup Type: {{backup_type}}',
body_text_de: 'Backup abgeschlossen\n\nDas geplante Backup wurde erfolgreich abgeschlossen.\n\nStartzeit: {{start_time}}\nDauer: {{duration}}\nGesicherte Dateien: {{files_count}}\nGesamtgröße: {{total_size}}\nBackup-Typ: {{backup_type}}',
variables: JSON.stringify(['start_time', 'duration', 'files_count', 'total_size', 'backup_type'])
}
];
// Insert backup email templates if they don't exist
for (const template of backupEmailTemplates) {
const exists = await db('email_templates')
.where('template_key', template.template_key)
.first();
if (!exists) {
await db('email_templates').insert(template);
}
}
console.log('Backup service tables and settings added successfully');
}
async function down() {
// Remove backup tables
await db.schema.dropTableIfExists('backup_file_states');
await db.schema.dropTableIfExists('backup_runs');
// Remove backup settings
await db('app_settings')
.where('setting_type', 'backup')
.delete();
// Remove backup email templates
await db('email_templates')
.whereIn('template_key', ['backup_failed', 'backup_completed'])
.delete();
}
module.exports = { up, down };
@@ -0,0 +1,182 @@
const { db } = require('../src/database/db');
async function up() {
console.log('Adding database backup tables and settings...');
// Create database_backup_runs table to track database backup history
const hasDatabaseBackupRunsTable = await db.schema.hasTable('database_backup_runs');
if (!hasDatabaseBackupRunsTable) {
await db.schema.createTable('database_backup_runs', (table) => {
table.increments('id').primary();
table.datetime('started_at').notNullable();
table.datetime('completed_at');
table.string('status').defaultTo('running'); // running, completed, failed
table.string('backup_type'); // sqlite, postgresql
table.string('destination_path');
table.string('file_path');
table.bigInteger('file_size_bytes').defaultTo(0);
table.bigInteger('original_size_bytes').defaultTo(0);
table.integer('duration_seconds');
table.string('checksum'); // SHA256 checksum of backup file
table.float('compression_ratio'); // Compression percentage
table.json('table_checksums'); // Individual table checksums
table.text('error_message');
table.json('statistics'); // Detailed stats about the backup
table.index(['started_at'], 'idx_db_backup_started');
table.index(['status'], 'idx_db_backup_status');
});
}
// Add database backup-related settings to app_settings
const databaseBackupSettings = [
{
setting_key: 'database_backup_enabled',
setting_value: JSON.stringify(false),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_schedule',
setting_value: JSON.stringify('0 3 * * *'), // Default: 3 AM daily
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_destination_path',
setting_value: JSON.stringify('/backup/database'),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_compress',
setting_value: JSON.stringify(true),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_validate_integrity',
setting_value: JSON.stringify(true),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_include_checksums',
setting_value: JSON.stringify(true),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_retention_days',
setting_value: JSON.stringify(30),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_email_on_failure',
setting_value: JSON.stringify(true),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_email_on_success',
setting_value: JSON.stringify(false),
setting_type: 'database_backup'
},
{
setting_key: 'database_backup_max_retries',
setting_value: JSON.stringify(3),
setting_type: 'database_backup'
}
];
// Insert database backup settings if they don't exist
for (const setting of databaseBackupSettings) {
const exists = await db('app_settings')
.where('setting_key', setting.setting_key)
.first();
if (!exists) {
await db('app_settings').insert(setting);
}
}
// Add database backup-related email templates
const databaseBackupEmailTemplates = [
{
template_key: 'database_backup_failed',
subject_en: 'Database Backup Failed - Critical Alert',
subject_de: 'Datenbank-Backup fehlgeschlagen - Kritische Warnung',
body_html_en: `<h2>Database Backup Failed</h2>
<p>The scheduled database backup has failed and requires immediate attention.</p>
<p><strong>Error Details:</strong></p>
<ul>
<li>Backup Type: {{backup_type}}</li>
<li>Timestamp: {{timestamp}}</li>
<li>Error: {{error_message}}</li>
</ul>
<p>This is a critical issue that could affect disaster recovery. Please investigate immediately.</p>`,
body_html_de: `<h2>Datenbank-Backup fehlgeschlagen</h2>
<p>Das geplante Datenbank-Backup ist fehlgeschlagen und erfordert sofortige Aufmerksamkeit.</p>
<p><strong>Fehlerdetails:</strong></p>
<ul>
<li>Backup-Typ: {{backup_type}}</li>
<li>Zeitstempel: {{timestamp}}</li>
<li>Fehler: {{error_message}}</li>
</ul>
<p>Dies ist ein kritisches Problem, das die Disaster-Recovery beeinträchtigen könnte. Bitte untersuchen Sie es sofort.</p>`,
body_text_en: 'Database Backup Failed\n\nThe scheduled database backup has failed.\n\nBackup Type: {{backup_type}}\nTimestamp: {{timestamp}}\nError: {{error_message}}\n\nThis is critical - please investigate immediately.',
body_text_de: 'Datenbank-Backup fehlgeschlagen\n\nDas geplante Datenbank-Backup ist fehlgeschlagen.\n\nBackup-Typ: {{backup_type}}\nZeitstempel: {{timestamp}}\nFehler: {{error_message}}\n\nDies ist kritisch - bitte sofort untersuchen.',
variables: JSON.stringify(['backup_type', 'timestamp', 'error_message'])
},
{
template_key: 'database_backup_completed',
subject_en: 'Database Backup Completed Successfully',
subject_de: 'Datenbank-Backup erfolgreich abgeschlossen',
body_html_en: `<h2>Database Backup Completed</h2>
<p>The scheduled database backup has been completed successfully.</p>
<p><strong>Backup Summary:</strong></p>
<ul>
<li>Backup Type: {{backup_type}}</li>
<li>Duration: {{duration}}</li>
<li>File Size: {{file_size}}</li>
<li>Compression Ratio: {{compression_ratio}}</li>
<li>File Path: {{file_path}}</li>
</ul>`,
body_html_de: `<h2>Datenbank-Backup abgeschlossen</h2>
<p>Das geplante Datenbank-Backup wurde erfolgreich abgeschlossen.</p>
<p><strong>Backup-Zusammenfassung:</strong></p>
<ul>
<li>Backup-Typ: {{backup_type}}</li>
<li>Dauer: {{duration}}</li>
<li>Dateigröße: {{file_size}}</li>
<li>Komprimierungsverhältnis: {{compression_ratio}}</li>
<li>Dateipfad: {{file_path}}</li>
</ul>`,
body_text_en: 'Database Backup Completed\n\nThe scheduled database backup has been completed successfully.\n\nBackup Type: {{backup_type}}\nDuration: {{duration}}\nFile Size: {{file_size}}\nCompression Ratio: {{compression_ratio}}\nFile Path: {{file_path}}',
body_text_de: 'Datenbank-Backup abgeschlossen\n\nDas geplante Datenbank-Backup wurde erfolgreich abgeschlossen.\n\nBackup-Typ: {{backup_type}}\nDauer: {{duration}}\nDateigröße: {{file_size}}\nKomprimierungsverhältnis: {{compression_ratio}}\nDateipfad: {{file_path}}',
variables: JSON.stringify(['backup_type', 'duration', 'file_size', 'compression_ratio', 'file_path'])
}
];
// Insert database backup email templates if they don't exist
for (const template of databaseBackupEmailTemplates) {
const exists = await db('email_templates')
.where('template_key', template.template_key)
.first();
if (!exists) {
await db('email_templates').insert(template);
}
}
console.log('Database backup tables and settings added successfully');
}
async function down() {
// Remove database backup tables
await db.schema.dropTableIfExists('database_backup_runs');
// Remove database backup settings
await db('app_settings')
.where('setting_type', 'database_backup')
.delete();
// Remove database backup email templates
await db('email_templates')
.whereIn('template_key', ['database_backup_failed', 'database_backup_completed'])
.delete();
}
module.exports = { up, down };
@@ -0,0 +1,86 @@
const { db } = require('../src/database/db');
const logger = require('../src/utils/logger');
async function up() {
console.log('Adding backup manifest columns...');
// Add manifest columns to backup_runs table
const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path');
if (!hasManifestPath) {
await db.schema.alterTable('backup_runs', (table) => {
table.string('manifest_path'); // Path to the manifest file
table.string('manifest_id'); // Unique manifest ID
table.string('manifest_format').defaultTo('json'); // json or yaml
});
}
// Add backup manifest-related settings to app_settings
const manifestSettings = [
{
setting_key: 'backup_manifest_enabled',
setting_value: JSON.stringify(true),
setting_type: 'backup'
},
{
setting_key: 'backup_manifest_format',
setting_value: JSON.stringify('json'), // json or yaml
setting_type: 'backup'
},
{
setting_key: 'backup_manifest_path',
setting_value: JSON.stringify('/backup/manifests'),
setting_type: 'backup'
},
{
setting_key: 'backup_manifest_validate',
setting_value: JSON.stringify(true),
setting_type: 'backup'
},
{
setting_key: 'backup_manifest_include_checksums',
setting_value: JSON.stringify(true),
setting_type: 'backup'
}
];
// Insert manifest settings if they don't exist
for (const setting of manifestSettings) {
const exists = await db('app_settings')
.where('setting_key', setting.setting_key)
.first();
if (!exists) {
await db('app_settings').insert(setting);
}
}
console.log('✓ Backup manifest columns and settings added');
}
async function down() {
// Remove manifest columns from backup_runs table
const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path');
if (hasManifestPath) {
await db.schema.alterTable('backup_runs', (table) => {
table.dropColumn('manifest_path');
table.dropColumn('manifest_id');
table.dropColumn('manifest_format');
});
}
// Remove manifest settings
await db('app_settings')
.where('setting_type', 'backup')
.whereIn('setting_key', [
'backup_manifest_enabled',
'backup_manifest_format',
'backup_manifest_path',
'backup_manifest_validate',
'backup_manifest_include_checksums'
])
.delete();
console.log('✓ Backup manifest columns and settings removed');
}
module.exports = { up, down };
@@ -0,0 +1,131 @@
exports.up = function(knex) {
return knex.schema
// Add new settings to app_settings table
.table('app_settings', table => {
// S3 configuration enhancements
table.boolean('backup_s3_force_path_style').defaultTo(false).comment('Force path-style S3 URLs (for MinIO/self-hosted)');
table.boolean('backup_s3_ssl_enabled').defaultTo(true).comment('Enable SSL/TLS for S3 connections');
table.string('backup_s3_prefix', 255).comment('S3 key prefix for organizing backups');
// Backup features
table.boolean('backup_incremental').defaultTo(false).comment('Enable incremental backups');
table.boolean('backup_include_database').defaultTo(true).comment('Include database dumps in backups');
table.boolean('backup_manifest_enabled').defaultTo(true).comment('Generate backup manifests');
table.enum('backup_manifest_format', ['json', 'yaml']).defaultTo('json').comment('Manifest file format');
table.boolean('backup_encryption_enabled').defaultTo(false).comment('Enable backup encryption');
table.string('backup_database_schedule', 100).comment('Separate cron schedule for database-only backups');
})
// Enhance backup_runs table
.table('backup_runs', table => {
// Manifest tracking
table.string('manifest_path', 500).comment('Path to backup manifest file');
table.uuid('manifest_id').comment('Unique identifier for the manifest');
table.enum('manifest_format', ['json', 'yaml']).comment('Format of the manifest file');
// Incremental backup support
table.integer('parent_backup_id').unsigned().references('id').inTable('backup_runs').onDelete('SET NULL').comment('Parent backup for incremental backups');
table.enum('backup_mode', ['full', 'incremental', 'database']).defaultTo('full').comment('Type of backup performed');
// Add indexes for better query performance
table.index(['backup_mode', 'status'], 'idx_backup_runs_mode_status');
table.index(['parent_backup_id'], 'idx_backup_runs_parent');
table.index(['created_at', 'backup_mode'], 'idx_backup_runs_created_mode');
})
// Create backup_manifest table for storing detailed manifest metadata
.createTable('backup_manifest', table => {
table.increments('id').primary();
table.integer('backup_run_id').unsigned().notNullable().references('id').inTable('backup_runs').onDelete('CASCADE');
table.uuid('manifest_id').notNullable().unique().comment('Unique identifier matching backup_runs.manifest_id');
table.string('version', 20).notNullable().defaultTo('1.0.0').comment('Manifest schema version');
table.enum('format', ['json', 'yaml']).notNullable().defaultTo('json');
// Backup metadata
table.timestamp('backup_start').notNullable();
table.timestamp('backup_end').notNullable();
table.bigInteger('total_size').unsigned().comment('Total size of backup in bytes');
table.integer('file_count').unsigned().comment('Number of files in backup');
table.integer('photo_count').unsigned().comment('Number of photos backed up');
table.integer('event_count').unsigned().comment('Number of events backed up');
// Incremental backup metadata
table.boolean('is_incremental').defaultTo(false);
table.uuid('parent_manifest_id').comment('Parent manifest ID for incremental backups');
table.timestamp('incremental_since').comment('Timestamp for incremental backup baseline');
// Content checksums
table.string('checksum_algorithm', 50).defaultTo('sha256').comment('Algorithm used for checksums');
table.text('manifest_checksum').comment('Checksum of the manifest file itself');
// Storage information
table.string('storage_location', 500).comment('Primary storage location (local path or S3 URI)');
table.string('storage_provider', 50).comment('Storage provider (local, s3, etc.)');
// Encryption metadata
table.boolean('is_encrypted').defaultTo(false);
table.string('encryption_algorithm', 100).comment('Encryption algorithm used');
table.string('encryption_key_id', 255).comment('ID of encryption key used');
// Additional metadata as JSON
table.json('metadata').comment('Additional metadata as JSON');
// Timestamps
table.timestamps(true, true);
// Indexes
table.index(['backup_run_id'], 'idx_manifest_backup_run');
table.index(['manifest_id'], 'idx_manifest_uuid');
table.index(['parent_manifest_id'], 'idx_manifest_parent');
table.index(['backup_start', 'backup_end'], 'idx_manifest_time_range');
table.index(['is_incremental', 'created_at'], 'idx_manifest_incremental_created');
})
// Add composite indexes for common query patterns
.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful
ON backup_runs(created_at DESC)
WHERE status = 'completed' AND backup_mode = 'full';
`)
.raw(`
CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain
ON backup_runs(parent_backup_id, created_at)
WHERE backup_mode = 'incremental';
`);
};
exports.down = function(knex) {
return knex.schema
// Drop indexes first
.raw('DROP INDEX IF EXISTS idx_backup_runs_incremental_chain;')
.raw('DROP INDEX IF EXISTS idx_backup_runs_recent_successful;')
// Drop backup_manifest table
.dropTableIfExists('backup_manifest')
// Remove columns from backup_runs table
.table('backup_runs', table => {
table.dropIndex(['backup_mode', 'status'], 'idx_backup_runs_mode_status');
table.dropIndex(['parent_backup_id'], 'idx_backup_runs_parent');
table.dropIndex(['created_at', 'backup_mode'], 'idx_backup_runs_created_mode');
table.dropColumn('manifest_path');
table.dropColumn('manifest_id');
table.dropColumn('manifest_format');
table.dropColumn('parent_backup_id');
table.dropColumn('backup_mode');
})
// Remove columns from app_settings table
.table('app_settings', table => {
table.dropColumn('backup_s3_force_path_style');
table.dropColumn('backup_s3_ssl_enabled');
table.dropColumn('backup_s3_prefix');
table.dropColumn('backup_incremental');
table.dropColumn('backup_include_database');
table.dropColumn('backup_manifest_enabled');
table.dropColumn('backup_manifest_format');
table.dropColumn('backup_encryption_enabled');
table.dropColumn('backup_database_schedule');
});
};
@@ -0,0 +1,249 @@
const { formatBoolean, parseBoolean } = require('./helpers');
/**
* Add restore_runs table for tracking restore operations
*/
exports.up = async function(knex) {
// Create restore_runs table
await knex.schema.createTable('restore_runs', table => {
table.increments('id').primary();
// Timing
table.timestamp('started_at').notNullable().defaultTo(knex.fn.now());
table.timestamp('completed_at');
table.integer('duration_seconds');
// Status and type
table.string('status', 50).notNullable().defaultTo('running');
table.string('restore_type', 50).notNullable(); // full, database, files, selective
// Source information
table.string('source', 500).notNullable(); // Backup source path or S3 URL
table.string('manifest_path', 500); // Path to manifest file
// Results
table.text('error_message');
table.text('statistics'); // JSON object with detailed statistics
table.text('restore_log'); // JSON array of log entries
// Safety backup
table.string('pre_restore_backup_path', 500); // Path to pre-restore safety backup
// Flags
table.boolean('is_dry_run').defaultTo(formatBoolean(false));
table.boolean('was_rollback_attempted').defaultTo(formatBoolean(false));
table.boolean('was_successful').defaultTo(formatBoolean(false));
// Operator information
table.string('operator_type', 50).defaultTo('manual'); // manual, scheduled, api
table.integer('operator_user_id').references('id').inTable('admin_users').onDelete('SET NULL');
table.string('operator_ip', 50);
// Metadata
table.text('metadata'); // JSON object for additional data
table.index(['status', 'started_at']);
table.index(['restore_type', 'started_at']);
});
// Create restore_file_operations table for tracking individual file operations
await knex.schema.createTable('restore_file_operations', table => {
table.increments('id').primary();
table.integer('restore_run_id').notNullable()
.references('id').inTable('restore_runs').onDelete('CASCADE');
table.string('file_path', 500).notNullable();
table.string('operation', 50).notNullable(); // restore, skip, error
table.string('status', 50).notNullable(); // pending, in_progress, completed, failed
table.bigInteger('file_size');
table.string('checksum', 64);
table.boolean('checksum_verified').defaultTo(formatBoolean(false));
table.text('error_message');
table.timestamp('started_at');
table.timestamp('completed_at');
table.index(['restore_run_id', 'status']);
table.index(['file_path']);
});
// Create restore_validation_results table
await knex.schema.createTable('restore_validation_results', table => {
table.increments('id').primary();
table.integer('restore_run_id').notNullable()
.references('id').inTable('restore_runs').onDelete('CASCADE');
table.string('validation_type', 50).notNullable(); // pre-restore, post-restore
table.boolean('is_valid').notNullable();
table.text('errors'); // JSON array of errors
table.text('warnings'); // JSON array of warnings
table.text('checksums'); // JSON object with checksum comparisons
table.timestamp('validated_at').notNullable().defaultTo(knex.fn.now());
table.index(['restore_run_id', 'validation_type']);
});
// Add restore-related settings to app_settings
await knex('app_settings').insert([
{
setting_key: 'restore_allow_force',
setting_value: formatBoolean(false),
setting_type: 'restore',
description: 'Allow force restore with warnings',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
setting_key: 'restore_require_pre_backup',
setting_value: formatBoolean(true),
setting_type: 'restore',
description: 'Require pre-restore backup',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
setting_key: 'restore_max_file_size_mb',
setting_value: '5000',
setting_type: 'restore',
description: 'Maximum file size for restore (MB)',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
setting_key: 'restore_verify_checksums',
setting_value: formatBoolean(true),
setting_type: 'restore',
description: 'Verify file checksums during restore',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
setting_key: 'restore_email_on_completion',
setting_value: formatBoolean(true),
setting_type: 'restore',
description: 'Send email on restore completion',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
setting_key: 'restore_retention_days',
setting_value: '30',
setting_type: 'restore',
description: 'Days to retain restore history',
created_at: knex.fn.now(),
updated_at: knex.fn.now()
}
]);
// Add new email templates for restore notifications
const emailTemplates = [
{
name: 'restore_completed',
subject: '✅ Restore Completed Successfully',
body: `<h2>Restore Operation Completed</h2>
<p>A restore operation has completed successfully.</p>
<h3>Details:</h3>
<ul>
<li><strong>Restore Type:</strong> {{restore_type}}</li>
<li><strong>Duration:</strong> {{duration}}</li>
<li><strong>Files Restored:</strong> {{files_restored}}</li>
<li><strong>Backup ID:</strong> {{backup_id}}</li>
<li><strong>Timestamp:</strong> {{timestamp}}</li>
</ul>
<p>Please verify that all systems are functioning correctly after the restore.</p>`,
language: 'en',
is_active: formatBoolean(true),
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
name: 'restore_failed',
subject: '❌ Restore Operation Failed',
body: `<h2>Restore Operation Failed</h2>
<p>A restore operation has failed and requires attention.</p>
<h3>Details:</h3>
<ul>
<li><strong>Restore Type:</strong> {{restore_type}}</li>
<li><strong>Error:</strong> {{error_message}}</li>
<li><strong>Timestamp:</strong> {{timestamp}}</li>
</ul>
<p>Please check the system logs for more details and take appropriate action.</p>
<p><strong>Important:</strong> If a pre-restore backup was created, it may be used for recovery.</p>`,
language: 'en',
is_active: formatBoolean(true),
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
name: 'restore_completed',
subject: '✅ Wiederherstellung erfolgreich abgeschlossen',
body: `<h2>Wiederherstellungsvorgang abgeschlossen</h2>
<p>Ein Wiederherstellungsvorgang wurde erfolgreich abgeschlossen.</p>
<h3>Details:</h3>
<ul>
<li><strong>Wiederherstellungstyp:</strong> {{restore_type}}</li>
<li><strong>Dauer:</strong> {{duration}}</li>
<li><strong>Wiederhergestellte Dateien:</strong> {{files_restored}}</li>
<li><strong>Backup-ID:</strong> {{backup_id}}</li>
<li><strong>Zeitstempel:</strong> {{timestamp}}</li>
</ul>
<p>Bitte überprüfen Sie, ob alle Systeme nach der Wiederherstellung ordnungsgemäß funktionieren.</p>`,
language: 'de',
is_active: formatBoolean(true),
created_at: knex.fn.now(),
updated_at: knex.fn.now()
},
{
name: 'restore_failed',
subject: '❌ Wiederherstellungsvorgang fehlgeschlagen',
body: `<h2>Wiederherstellungsvorgang fehlgeschlagen</h2>
<p>Ein Wiederherstellungsvorgang ist fehlgeschlagen und erfordert Ihre Aufmerksamkeit.</p>
<h3>Details:</h3>
<ul>
<li><strong>Wiederherstellungstyp:</strong> {{restore_type}}</li>
<li><strong>Fehler:</strong> {{error_message}}</li>
<li><strong>Zeitstempel:</strong> {{timestamp}}</li>
</ul>
<p>Bitte überprüfen Sie die Systemprotokolle für weitere Details und ergreifen Sie entsprechende Maßnahmen.</p>
<p><strong>Wichtig:</strong> Falls ein Backup vor der Wiederherstellung erstellt wurde, kann es zur Wiederherstellung verwendet werden.</p>`,
language: 'de',
is_active: formatBoolean(true),
created_at: knex.fn.now(),
updated_at: knex.fn.now()
}
];
await knex('email_templates').insert(emailTemplates);
};
exports.down = async function(knex) {
// Remove email templates
await knex('email_templates')
.whereIn('name', ['restore_completed', 'restore_failed'])
.delete();
// Remove settings
await knex('app_settings')
.where('setting_type', 'restore')
.delete();
// Drop tables
await knex.schema.dropTableIfExists('restore_validation_results');
await knex.schema.dropTableIfExists('restore_file_operations');
await knex.schema.dropTableIfExists('restore_runs');
};
+1812 -11
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.0.56",
"version": "1.0.75",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -8,10 +8,16 @@
"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/"
"lint": "eslint src/",
"test-backup": "node scripts/test-backup-service.js",
"test-restore": "node scripts/test-restore-service.js"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
"@aws-sdk/s3-request-presigner": "^3.850.0",
"adm-zip": "^0.5.16",
"archiver": "^5.3.1",
"axios": "^1.10.0",
@@ -29,8 +35,10 @@
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"joi": "^17.9.1",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.2",
"mime-types": "^3.0.1",
"multer": "^2.0.1",
"node-cron": "^3.0.2",
"nodemailer": "^6.9.1",
+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);
+577
View File
@@ -0,0 +1,577 @@
#!/usr/bin/env node
/**
* Manual Integration Test Script for Enhanced Backup System
*
* This script provides a comprehensive test of the backup system with real services.
* It can be used to test against MinIO, AWS S3, or other S3-compatible services.
*
* Usage:
* node scripts/test-backup-integration.js [options]
*
* Options:
* --endpoint <url> S3 endpoint URL (default: http://localhost:9000)
* --access-key <key> S3 access key (default: minioadmin)
* --secret-key <key> S3 secret key (default: minioadmin)
* --bucket <name> S3 bucket name (default: test-backup-<timestamp>)
* --type <type> Backup type: s3, local, rsync (default: s3)
* --cleanup Clean up test data after completion
* --verbose Enable verbose logging
* --help Show this help message
*
* Examples:
* # Test with local MinIO
* node scripts/test-backup-integration.js
*
* # Test with AWS S3
* node scripts/test-backup-integration.js \
* --endpoint https://s3.amazonaws.com \
* --access-key AKIAIOSFODNN7EXAMPLE \
* --secret-key wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY \
* --bucket my-test-bucket
*
* # Test local backup
* node scripts/test-backup-integration.js --type local
*/
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
const { S3Client, CreateBucketCommand, HeadBucketCommand, ListObjectsV2Command, GetObjectCommand, DeleteObjectsCommand, DeleteBucketCommand } = require('@aws-sdk/client-s3');
// Parse command line arguments
const args = process.argv.slice(2);
const options = {
endpoint: 'http://localhost:9000',
accessKey: 'minioadmin',
secretKey: 'minioadmin',
bucket: `test-backup-${Date.now()}`,
type: 's3',
cleanup: false,
verbose: false
};
for (let i = 0; i < args.length; i++) {
switch (args[i]) {
case '--endpoint':
options.endpoint = args[++i];
break;
case '--access-key':
options.accessKey = args[++i];
break;
case '--secret-key':
options.secretKey = args[++i];
break;
case '--bucket':
options.bucket = args[++i];
break;
case '--type':
options.type = args[++i];
break;
case '--cleanup':
options.cleanup = true;
break;
case '--verbose':
options.verbose = true;
break;
case '--help':
console.log(module.exports.description || 'Manual Integration Test Script');
process.exit(0);
}
}
// Load environment and services
require('dotenv').config();
const { db, initialize: initDb } = require('../src/database/db');
const backupService = require('../src/services/backupService');
const S3StorageAdapter = require('../src/services/storage/s3Storage');
const logger = require('../src/utils/logger');
// Configure logger based on verbose flag
if (!options.verbose) {
logger.info = () => {};
logger.debug = () => {};
}
// Test results
const results = {
passed: 0,
failed: 0,
skipped: 0,
tests: []
};
// Test utilities
async function runTest(name, testFn) {
console.log(`\n📋 Running: ${name}`);
try {
const startTime = Date.now();
await testFn();
const duration = Date.now() - startTime;
console.log(`✅ PASSED: ${name} (${duration}ms)`);
results.passed++;
results.tests.push({ name, status: 'passed', duration });
} catch (error) {
console.error(`❌ FAILED: ${name}`);
console.error(` Error: ${error.message}`);
if (options.verbose) {
console.error(error.stack);
}
results.failed++;
results.tests.push({ name, status: 'failed', error: error.message });
}
}
async function skipTest(name, reason) {
console.log(`\n⏭️ Skipping: ${name}`);
console.log(` Reason: ${reason}`);
results.skipped++;
results.tests.push({ name, status: 'skipped', reason });
}
// Test functions
async function testS3Connection() {
const s3Adapter = new S3StorageAdapter({
bucket: options.bucket,
endpoint: options.endpoint,
accessKeyId: options.accessKey,
secretAccessKey: options.secretKey,
region: 'us-east-1',
forcePathStyle: true,
sslEnabled: options.endpoint.startsWith('https')
});
await s3Adapter.testConnection();
console.log(` ✓ Connected to S3 endpoint: ${options.endpoint}`);
console.log(` ✓ Bucket accessible: ${options.bucket}`);
}
async function setupTestData() {
const storagePath = path.join(__dirname, '../test-storage');
process.env.STORAGE_PATH = storagePath;
// Create directory structure
const dirs = [
'events/active/wedding-2024',
'events/active/birthday-2024',
'events/archived',
'thumbnails',
'uploads',
'backups'
];
for (const dir of dirs) {
await fs.mkdir(path.join(storagePath, dir), { recursive: true });
}
// Create test files with various sizes
const files = [
{ path: 'events/active/wedding-2024/photo1.jpg', size: 1024 * 1024 }, // 1MB
{ path: 'events/active/wedding-2024/photo2.jpg', size: 512 * 1024 }, // 512KB
{ path: 'events/active/birthday-2024/photo1.jpg', size: 2 * 1024 * 1024 }, // 2MB
{ path: 'events/archived/old-event.zip', size: 5 * 1024 * 1024 }, // 5MB
{ path: 'thumbnails/thumb1.jpg', size: 50 * 1024 }, // 50KB
{ path: 'uploads/logo.png', size: 100 * 1024 } // 100KB
];
let totalSize = 0;
for (const file of files) {
const content = crypto.randomBytes(file.size);
await fs.writeFile(path.join(storagePath, file.path), content);
totalSize += file.size;
}
console.log(` ✓ Created ${files.length} test files`);
console.log(` ✓ Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`);
return { storagePath, fileCount: files.length, totalSize };
}
async function configureBackup(type) {
const baseSettings = [
{ setting_key: 'backup_enabled', setting_value: 'true' },
{ setting_key: 'backup_destination_type', setting_value: `"${type}"` },
{ setting_key: 'backup_include_archived', setting_value: 'true' },
{ setting_key: 'backup_include_database', setting_value: 'true' },
{ setting_key: 'backup_incremental', setting_value: 'true' },
{ setting_key: 'backup_manifest_format', setting_value: '"json"' },
{ setting_key: 'backup_max_file_size_mb', setting_value: '100' }
];
const typeSpecificSettings = {
s3: [
{ setting_key: 'backup_s3_bucket', setting_value: `"${options.bucket}"` },
{ setting_key: 'backup_s3_endpoint', setting_value: `"${options.endpoint}"` },
{ setting_key: 'backup_s3_access_key', setting_value: `"${options.accessKey}"` },
{ setting_key: 'backup_s3_secret_key', setting_value: `"${options.secretKey}"` },
{ setting_key: 'backup_s3_region', setting_value: '"us-east-1"' },
{ setting_key: 'backup_s3_force_path_style', setting_value: 'true' },
{ setting_key: 'backup_s3_ssl_enabled', setting_value: options.endpoint.startsWith('https') ? 'true' : 'false' }
],
local: [
{ setting_key: 'backup_destination_path', setting_value: `"${path.join(__dirname, '../test-backup')}"` }
],
rsync: [
{ setting_key: 'backup_rsync_host', setting_value: '"localhost"' },
{ setting_key: 'backup_rsync_path', setting_value: `"${path.join(__dirname, '../test-backup-rsync')}"` }
]
};
const settings = [...baseSettings, ...(typeSpecificSettings[type] || [])];
// Clear existing settings
await db('app_settings').where('setting_type', 'backup').del();
// Insert new settings
for (const setting of settings) {
await db('app_settings').insert({
setting_type: 'backup',
...setting,
created_at: new Date(),
updated_at: new Date()
});
}
console.log(` ✓ Configured ${type} backup with ${settings.length} settings`);
}
async function performBackup() {
const startTime = Date.now();
// Run the backup
await backupService.runBackup();
// Get backup results
const backupRun = await db('backup_runs')
.orderBy('started_at', 'desc')
.first();
if (!backupRun) {
throw new Error('No backup run found');
}
if (backupRun.status !== 'completed') {
throw new Error(`Backup failed with status: ${backupRun.status}, error: ${backupRun.error_message}`);
}
const duration = Date.now() - startTime;
console.log(` ✓ Backup completed in ${duration}ms`);
console.log(` ✓ Files backed up: ${backupRun.files_backed_up}`);
console.log(` ✓ Total size: ${(backupRun.total_size_bytes / 1024 / 1024).toFixed(2)} MB`);
console.log(` ✓ Manifest: ${backupRun.manifest_path ? 'Generated' : 'Not generated'}`);
return backupRun;
}
async function verifyS3Backup(backupRun) {
const s3Client = new S3Client({
endpoint: options.endpoint,
region: 'us-east-1',
credentials: {
accessKeyId: options.accessKey,
secretAccessKey: options.secretKey
},
forcePathStyle: true
});
// List objects in bucket
const listResponse = await s3Client.send(new ListObjectsV2Command({
Bucket: options.bucket
}));
const objects = listResponse.Contents || [];
console.log(` ✓ Objects in S3: ${objects.length}`);
// Verify key components
const hasBackupFolder = objects.some(obj => obj.Key.includes('backup-'));
const hasManifest = objects.some(obj => obj.Key.includes('backup-manifest'));
const hasSummary = objects.some(obj => obj.Key.includes('backup-summary.json'));
const hasPhotos = objects.some(obj => obj.Key.includes('events/active'));
if (!hasBackupFolder) throw new Error('No backup folder found in S3');
if (!hasManifest) throw new Error('No manifest found in S3');
if (!hasSummary) throw new Error('No summary found in S3');
if (!hasPhotos) throw new Error('No photos found in S3');
console.log(` ✓ Backup structure verified`);
// Download and verify a file
const photoObject = objects.find(obj => obj.Key.includes('photo1.jpg'));
if (photoObject) {
const getResponse = await s3Client.send(new GetObjectCommand({
Bucket: options.bucket,
Key: photoObject.Key
}));
const chunks = [];
for await (const chunk of getResponse.Body) {
chunks.push(chunk);
}
const content = Buffer.concat(chunks);
console.log(` ✓ Downloaded test file: ${photoObject.Key} (${content.length} bytes)`);
}
}
async function testIncrementalBackup(testData) {
// Modify a file
const modifiedFile = path.join(testData.storagePath, 'events/active/wedding-2024/photo1.jpg');
const newContent = crypto.randomBytes(1024 * 1024 + 100); // Slightly larger
await fs.writeFile(modifiedFile, newContent);
console.log(` ✓ Modified test file`);
// Perform incremental backup
const backupRun = await performBackup();
if (backupRun.files_backed_up !== 1) {
throw new Error(`Expected 1 file in incremental backup, got ${backupRun.files_backed_up}`);
}
console.log(` ✓ Incremental backup correctly identified changed file`);
// Verify manifest indicates incremental
if (backupRun.manifest_path) {
const { manifest } = await backupService.getBackupManifest(backupRun.id);
if (!manifest.incremental) {
throw new Error('Manifest does not indicate incremental backup');
}
console.log(` ✓ Manifest correctly marked as incremental`);
}
return backupRun;
}
async function testManifestValidation(backupRun) {
if (!backupRun.manifest_path) {
throw new Error('No manifest path in backup run');
}
const result = await backupService.validateBackupManifest(backupRun.manifest_path);
if (!result.valid) {
throw new Error(`Manifest validation failed: ${result.error}`);
}
console.log(` ✓ Manifest validation passed`);
console.log(` ✓ Manifest version: ${result.manifest.manifest.version}`);
console.log(` ✓ Files in manifest: ${result.manifest.files.count}`);
}
async function testBackupStatus() {
const status = await backupService.getBackupStatus(5);
console.log(` ✓ Backup service running: ${status.isRunning}`);
console.log(` ✓ Backup service healthy: ${status.isHealthy}`);
console.log(` ✓ Recent runs: ${status.recentRuns.length}`);
if (status.lastRun) {
console.log(` ✓ Last run status: ${status.lastRun.status}`);
console.log(` ✓ Manifest valid: ${status.lastRun.manifestValid}`);
}
}
async function cleanupTestData() {
if (!options.cleanup) {
console.log('\n📌 Test data retained for inspection');
console.log(` Storage: ${process.env.STORAGE_PATH}`);
if (options.type === 's3') {
console.log(` S3 Bucket: ${options.bucket}`);
}
return;
}
console.log('\n🧹 Cleaning up test data...');
// Clean storage directory
if (process.env.STORAGE_PATH) {
await fs.rm(process.env.STORAGE_PATH, { recursive: true, force: true });
console.log(' ✓ Removed test storage directory');
}
// Clean S3 bucket if used
if (options.type === 's3') {
const s3Client = new S3Client({
endpoint: options.endpoint,
region: 'us-east-1',
credentials: {
accessKeyId: options.accessKey,
secretAccessKey: options.secretKey
},
forcePathStyle: true
});
try {
// List and delete all objects
const listResponse = await s3Client.send(new ListObjectsV2Command({
Bucket: options.bucket
}));
if (listResponse.Contents && listResponse.Contents.length > 0) {
await s3Client.send(new DeleteObjectsCommand({
Bucket: options.bucket,
Delete: {
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key }))
}
}));
console.log(` ✓ Deleted ${listResponse.Contents.length} objects from S3`);
}
// Delete bucket
await s3Client.send(new DeleteBucketCommand({
Bucket: options.bucket
}));
console.log(` ✓ Deleted S3 bucket: ${options.bucket}`);
} catch (error) {
console.error(` ⚠️ Failed to cleanup S3: ${error.message}`);
}
}
// Clean backup directories
const backupDirs = [
path.join(__dirname, '../test-backup'),
path.join(__dirname, '../test-backup-rsync')
];
for (const dir of backupDirs) {
await fs.rm(dir, { recursive: true, force: true }).catch(() => {});
}
console.log(' ✓ Removed backup directories');
}
// Main test runner
async function main() {
console.log('🚀 Enhanced Backup System Integration Test');
console.log('==========================================');
console.log(`Type: ${options.type}`);
console.log(`Endpoint: ${options.endpoint}`);
console.log(`Bucket: ${options.bucket}`);
console.log('');
let s3Client;
let testData;
try {
// Initialize database
console.log('📦 Initializing database...');
await initDb();
await db.migrate.latest();
console.log(' ✓ Database initialized');
// S3-specific setup
if (options.type === 's3') {
// Test S3 connection
await runTest('S3 Connection Test', testS3Connection);
// Create S3 bucket if needed
s3Client = new S3Client({
endpoint: options.endpoint,
region: 'us-east-1',
credentials: {
accessKeyId: options.accessKey,
secretAccessKey: options.secretKey
},
forcePathStyle: true
});
try {
await s3Client.send(new HeadBucketCommand({ Bucket: options.bucket }));
console.log(`\n📦 Using existing bucket: ${options.bucket}`);
} catch (error) {
if (error.name === 'NotFound') {
await s3Client.send(new CreateBucketCommand({ Bucket: options.bucket }));
console.log(`\n📦 Created new bucket: ${options.bucket}`);
} else {
throw error;
}
}
}
// Setup test data
console.log('\n📁 Setting up test data...');
testData = await setupTestData();
// Configure backup
console.log(`\n⚙️ Configuring ${options.type} backup...`);
await configureBackup(options.type);
// Run tests based on backup type
await runTest('Initial Full Backup', performBackup);
if (options.type === 's3') {
await runTest('Verify S3 Backup Contents', async () => {
const lastRun = await db('backup_runs').orderBy('started_at', 'desc').first();
await verifyS3Backup(lastRun);
});
}
await runTest('Incremental Backup', () => testIncrementalBackup(testData));
await runTest('Manifest Validation', async () => {
const lastRun = await db('backup_runs').orderBy('started_at', 'desc').first();
await testManifestValidation(lastRun);
});
await runTest('Backup Status Check', testBackupStatus);
// Performance test with larger files
if (options.type === 's3') {
await runTest('Large File Backup (10MB)', async () => {
const largeFile = path.join(testData.storagePath, 'events/active/large.jpg');
await fs.writeFile(largeFile, crypto.randomBytes(10 * 1024 * 1024));
await performBackup();
});
}
// Test backup service lifecycle
await runTest('Backup Service Start/Stop', async () => {
await backupService.startBackupService();
console.log(' ✓ Service started');
backupService.stopBackupService();
console.log(' ✓ Service stopped');
});
// Print results summary
console.log('\n📊 Test Results Summary');
console.log('======================');
console.log(`✅ Passed: ${results.passed}`);
console.log(`❌ Failed: ${results.failed}`);
console.log(`⏭️ Skipped: ${results.skipped}`);
console.log(`📋 Total: ${results.tests.length}`);
if (results.failed > 0) {
console.log('\nFailed Tests:');
results.tests
.filter(t => t.status === 'failed')
.forEach(t => console.log(` - ${t.name}: ${t.error}`));
}
} catch (error) {
console.error('\n💥 Fatal error:', error.message);
if (options.verbose) {
console.error(error.stack);
}
results.failed++;
} finally {
// Cleanup
await cleanupTestData();
// Close database
await db.destroy();
// Exit with appropriate code
process.exit(results.failed > 0 ? 1 : 0);
}
}
// Run if called directly
if (require.main === module) {
main().catch(error => {
console.error('Unhandled error:', error);
process.exit(1);
});
}
module.exports = { runTest, skipTest };
+192
View File
@@ -0,0 +1,192 @@
#!/usr/bin/env node
/**
* Test script for backup manifest generator
* Demonstrates all features of the manifest generator
*/
const path = require('path');
const fs = require('fs').promises;
const backupManifest = require('../src/services/backupManifest');
const logger = require('../src/utils/logger');
async function testManifestGeneration() {
console.log('=== Testing Backup Manifest Generator ===\n');
try {
// 1. Generate a full backup manifest
console.log('1. Generating full backup manifest...');
const fullManifestOptions = {
backupType: 'full',
backupPath: '/backup/full/2025-01-21',
files: [
{
path: '/storage/events/active/wedding-smith-2025/DSC_001.jpg',
relativePath: 'events/active/wedding-smith-2025/DSC_001.jpg',
size: 2456789,
modified: new Date('2025-01-20T10:30:00Z'),
checksum: 'a1b2c3d4e5f6789012345678901234567890123456789012345678901234567890',
permissions: '644'
},
{
path: '/storage/events/active/wedding-smith-2025/DSC_002.jpg',
relativePath: 'events/active/wedding-smith-2025/DSC_002.jpg',
size: 2156789,
modified: new Date('2025-01-20T10:31:00Z'),
checksum: 'b2c3d4e5f67890123456789012345678901234567890123456789012345678901',
permissions: '644'
},
{
path: '/storage/thumbnails/wedding-smith-2025/thumb_DSC_001.jpg',
relativePath: 'thumbnails/wedding-smith-2025/thumb_DSC_001.jpg',
size: 45678,
modified: new Date('2025-01-20T10:35:00Z'),
checksum: 'c3d4e5f678901234567890123456789012345678901234567890123456789012',
permissions: '644'
}
],
databaseInfo: {
type: 'sqlite',
backupFile: 'database-backup-20250121-103000.sql.gz',
size: 1048576,
checksum: 'd4e5f6789012345678901234567890123456789012345678901234567890123',
tables: {
events: 156,
photos: 4523,
access_logs: 12456,
admin_users: 3
},
rowCounts: {
events: 156,
photos: 4523,
access_logs: 12456,
admin_users: 3
}
},
format: 'json',
customMetadata: {
operator: 'admin@example.com',
reason: 'Scheduled daily backup',
retentionDays: 30,
compressionType: 'gzip'
}
};
const fullManifest = await backupManifest.generateManifest(fullManifestOptions);
// Save in both formats
const jsonPath = path.join(__dirname, 'test-manifest-full.json');
const yamlPath = path.join(__dirname, 'test-manifest-full.yaml');
await backupManifest.saveManifest(fullManifest, jsonPath, 'json');
await backupManifest.saveManifest(fullManifest, yamlPath, 'yaml');
console.log('✓ Full backup manifest generated and saved\n');
// 2. Generate summary report
console.log('2. Generating summary report...');
const summaryReport = backupManifest.generateSummaryReport(fullManifest);
console.log(summaryReport);
console.log('\n');
// 3. Load and validate manifest
console.log('3. Loading and validating manifest...');
const loadedManifest = await backupManifest.loadManifest(jsonPath);
console.log('✓ Manifest loaded and validated successfully\n');
// 4. Generate incremental backup manifest
console.log('4. Generating incremental backup manifest...');
const incrementalOptions = {
backupType: 'incremental',
backupPath: '/backup/incremental/2025-01-22',
parentBackupId: fullManifest.backup.id,
files: [
// Original files with same checksums (unchanged)
fullManifestOptions.files[0],
fullManifestOptions.files[2],
// Modified file
{
...fullManifestOptions.files[1],
size: 2256789,
modified: new Date('2025-01-21T14:00:00Z'),
checksum: 'e5f678901234567890123456789012345678901234567890123456789012345'
},
// New file
{
path: '/storage/events/active/wedding-smith-2025/DSC_003.jpg',
relativePath: 'events/active/wedding-smith-2025/DSC_003.jpg',
size: 2356789,
modified: new Date('2025-01-21T14:30:00Z'),
checksum: 'f6789012345678901234567890123456789012345678901234567890123456',
permissions: '644'
}
],
databaseInfo: {
...fullManifestOptions.databaseInfo,
size: 1148576,
checksum: 'g7890123456789012345678901234567890123456789012345678901234567',
rowCounts: {
events: 158,
photos: 4567,
access_logs: 12789,
admin_users: 3
}
}
};
const incrementalManifest = await backupManifest.generateIncrementalManifest(
incrementalOptions,
fullManifest
);
const incrementalJsonPath = path.join(__dirname, 'test-manifest-incremental.json');
await backupManifest.saveManifest(incrementalManifest, incrementalJsonPath, 'json');
console.log('✓ Incremental backup manifest generated');
console.log(` - Added files: ${incrementalManifest.incremental.changes.added_files_count}`);
console.log(` - Modified files: ${incrementalManifest.incremental.changes.modified_files_count}`);
console.log(` - Deleted files: ${incrementalManifest.incremental.changes.deleted_files_count}`);
console.log(` - Size difference: ${(incrementalManifest.incremental.changes.size_difference / 1024).toFixed(2)} KB\n`);
// 5. Compare manifests
console.log('5. Comparing manifests...');
const comparison = backupManifest.compareManifests(incrementalManifest, fullManifest);
console.log('Comparison results:');
console.log(` - Added: ${comparison.added_files.length} files`);
console.log(` - Modified: ${comparison.modified_files.length} files`);
console.log(` - Deleted: ${comparison.deleted_files.length} files`);
console.log(` - Unchanged: ${comparison.unchanged_files.length} files`);
console.log(` - Database changed: ${comparison.database_changes.checksum_changed ? 'Yes' : 'No'}\n`);
// 6. Test manifest integrity
console.log('6. Testing manifest integrity...');
// Corrupt the manifest
const corruptedManifest = JSON.parse(JSON.stringify(incrementalManifest));
corruptedManifest.files.manifest[0].size = 9999999; // Change a file size
try {
backupManifest.validateManifest(corruptedManifest);
console.log('✗ Validation should have failed for corrupted manifest');
} catch (error) {
console.log('✓ Correctly detected corrupted manifest:', error.message);
}
console.log('\n=== All tests completed successfully! ===');
// Clean up test files
await fs.unlink(jsonPath).catch(() => {});
await fs.unlink(yamlPath).catch(() => {});
await fs.unlink(incrementalJsonPath).catch(() => {});
} catch (error) {
console.error('Test failed:', error);
logger.error('Manifest test failed:', error);
process.exit(1);
}
}
// Run tests
testManifestGeneration().catch(console.error);
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env node
require('dotenv').config();
const { initializeDatabase } = require('../src/database/db');
const { runBackup, getBackupStatus } = require('../src/services/backupService');
const logger = require('../src/utils/logger');
async function testBackupService() {
try {
console.log('Testing backup service...\n');
// Initialize database
await initializeDatabase();
// Get current backup status
console.log('Getting backup status...');
const statusBefore = await getBackupStatus();
console.log('Last run:', statusBefore.lastRun ? statusBefore.lastRun.started_at : 'Never');
console.log('Is healthy:', statusBefore.isHealthy);
console.log('');
// Run backup
console.log('Running backup...');
await runBackup();
// Get status after backup
console.log('\nGetting status after backup...');
const statusAfter = await getBackupStatus();
console.log('Last run:', statusAfter.lastRun ? statusAfter.lastRun.started_at : 'Never');
console.log('Status:', statusAfter.lastRun ? statusAfter.lastRun.status : 'Unknown');
console.log('Files backed up:', statusAfter.lastRun ? statusAfter.lastRun.files_backed_up : 0);
console.log('Total size:', statusAfter.lastRun ? `${(statusAfter.lastRun.total_size_bytes / 1024 / 1024).toFixed(2)} MB` : '0 MB');
if (statusAfter.lastRun && statusAfter.lastRun.error_message) {
console.log('Error:', statusAfter.lastRun.error_message);
}
console.log('\nBackup test completed!');
process.exit(0);
} catch (error) {
console.error('Test failed:', error);
process.exit(1);
}
}
testBackupService();
+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!');
+325
View File
@@ -0,0 +1,325 @@
/**
* Test script for the restore service
*
* This script demonstrates the restore service functionality with safety checks
*
* Usage:
* node scripts/test-restore-service.js [options]
*
* Options:
* --dry-run Perform validation only without actual restore
* --force Force restore even with warnings
* --type Restore type: full, database, files, selective (default: full)
* --source Backup source path or S3 URL
* --manifest Path to backup manifest
*/
require('dotenv').config();
const { restoreService } = require('../src/services/restoreService');
const { db } = require('../src/database/db');
const logger = require('../src/utils/logger');
const path = require('path');
const fs = require('fs').promises;
// Parse command line arguments
const args = process.argv.slice(2);
const options = {
dryRun: args.includes('--dry-run'),
force: args.includes('--force'),
restoreType: 'full',
source: null,
manifestPath: null
};
// Parse restore type
const typeIndex = args.indexOf('--type');
if (typeIndex !== -1 && args[typeIndex + 1]) {
options.restoreType = args[typeIndex + 1];
}
// Parse source
const sourceIndex = args.indexOf('--source');
if (sourceIndex !== -1 && args[sourceIndex + 1]) {
options.source = args[sourceIndex + 1];
}
// Parse manifest
const manifestIndex = args.indexOf('--manifest');
if (manifestIndex !== -1 && args[manifestIndex + 1]) {
options.manifestPath = args[manifestIndex + 1];
}
async function testRestore() {
console.log('=== PicPeak Restore Service Test ===\n');
try {
// If no source/manifest provided, try to find a recent backup
if (!options.source || !options.manifestPath) {
console.log('No backup source specified. Looking for recent backups...\n');
const recentBackup = await db('backup_runs')
.where('status', 'completed')
.whereNotNull('manifest_path')
.orderBy('completed_at', 'desc')
.first();
if (!recentBackup) {
console.error('❌ No completed backups found in the database');
console.log('\nPlease run a backup first or specify --source and --manifest');
process.exit(1);
}
console.log(`Found recent backup from ${recentBackup.completed_at}`);
console.log(`Backup ID: ${recentBackup.manifest_id}`);
console.log(`Files backed up: ${recentBackup.files_backed_up}`);
console.log(`Total size: ${(recentBackup.total_size_bytes / 1024 / 1024).toFixed(2)} MB`);
console.log(`Manifest: ${recentBackup.manifest_path}\n`);
// For this test, we'll create a mock scenario
console.log('⚠️ This is a TEST MODE - using mock data for safety\n');
// Create test backup directory
const testBackupDir = path.join(__dirname, '../temp/test-backup');
await fs.mkdir(testBackupDir, { recursive: true });
// Create test manifest
const testManifest = {
manifest: {
version: '2.0',
created: new Date().toISOString(),
generator: 'Test Script',
format: 'json'
},
backup: {
id: 'test-backup-' + Date.now(),
type: 'full',
timestamp: new Date().toISOString(),
path: testBackupDir,
parent_backup_id: null,
retention_days: 30
},
system: {
hostname: require('os').hostname(),
platform: process.platform,
os_release: require('os').release(),
architecture: require('os').arch()
},
application: {
name: 'PicPeak',
version: require('../package.json').version,
node_version: process.version,
environment: 'test'
},
files: {
count: 0,
total_size: 0,
checksums: {},
manifest: []
},
database: {
type: process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite',
backup_file: null,
size: 0,
checksum: null,
tables: {},
row_counts: {}
},
verification: {
total_checksum: null,
file_count_check: 0,
size_check: 0,
integrity_timestamp: new Date().toISOString()
},
metadata: {
test_mode: true
}
};
// Calculate checksum
const crypto = require('crypto');
const manifestCopy = JSON.parse(JSON.stringify(testManifest));
delete manifestCopy.verification.total_checksum;
testManifest.verification.total_checksum = crypto
.createHash('sha256')
.update(JSON.stringify(manifestCopy, Object.keys(manifestCopy).sort()))
.digest('hex');
// Save test manifest
const testManifestPath = path.join(testBackupDir, 'test-manifest.json');
await fs.writeFile(testManifestPath, JSON.stringify(testManifest, null, 2));
options.source = testBackupDir;
options.manifestPath = testManifestPath;
}
// Display restore options
console.log('Restore Options:');
console.log(`- Type: ${options.restoreType}`);
console.log(`- Source: ${options.source}`);
console.log(`- Manifest: ${options.manifestPath}`);
console.log(`- Dry Run: ${options.dryRun ? 'Yes' : 'No'}`);
console.log(`- Force: ${options.force ? 'Yes' : 'No'}`);
console.log('');
// Add S3 config if source is S3
if (options.source.startsWith('s3://')) {
options.s3Config = {
accessKeyId: process.env.BACKUP_S3_ACCESS_KEY,
secretAccessKey: process.env.BACKUP_S3_SECRET_KEY,
region: process.env.BACKUP_S3_REGION || 'us-east-1',
endpoint: process.env.BACKUP_S3_ENDPOINT
};
if (!options.s3Config.accessKeyId || !options.s3Config.secretAccessKey) {
console.error('❌ S3 credentials not configured in environment');
process.exit(1);
}
}
// Confirm before proceeding (unless dry run)
if (!options.dryRun) {
console.log('⚠️ WARNING: This will restore data from the backup!');
console.log('⚠️ Current data may be overwritten!');
console.log('');
console.log('Press Ctrl+C to cancel, or wait 5 seconds to continue...');
await new Promise(resolve => setTimeout(resolve, 5000));
}
console.log('\nStarting restore operation...\n');
// Perform restore
const result = await restoreService.restore(options);
if (options.dryRun) {
console.log('\n=== DRY RUN RESULTS ===\n');
console.log('Validation:');
console.log(`- Valid: ${result.validation.isValid ? '✅ Yes' : '❌ No'}`);
if (result.validation.errors.length > 0) {
console.log('- Errors:');
result.validation.errors.forEach(err => console.log(`${err}`));
}
if (result.validation.warnings.length > 0) {
console.log('- Warnings:');
result.validation.warnings.forEach(warn => console.log(` ⚠️ ${warn}`));
}
console.log('\nDisk Space:');
console.log(`- Required: ${result.spaceCheck.requiredFormatted}`);
console.log(`- Available: ${result.spaceCheck.availableFormatted}`);
console.log(`- Sufficient: ${result.spaceCheck.hasEnoughSpace ? '✅ Yes' : '❌ No'}`);
} else {
console.log('\n=== RESTORE RESULTS ===\n');
console.log(`Status: ${result.success ? '✅ SUCCESS' : '❌ FAILED'}`);
console.log(`Duration: ${result.duration}s`);
if (result.result) {
console.log('\nItems Restored:');
if (result.result.databaseRestored !== undefined) {
console.log(`- Database: ${result.result.databaseRestored ? '✅' : '❌'}`);
}
if (result.result.filesRestored !== undefined) {
console.log(`- Files: ${result.result.filesRestored}`);
}
if (result.result.errors && result.result.errors.length > 0) {
console.log('- Errors:');
result.result.errors.forEach(err => console.log(`${err}`));
}
}
if (result.verification) {
console.log('\nVerification:');
console.log(`- Valid: ${result.verification.isValid ? '✅ Yes' : '❌ No'}`);
if (result.verification.errors.length > 0) {
console.log('- Errors:');
result.verification.errors.forEach(err => console.log(`${err}`));
}
}
if (result.preRestoreBackup) {
console.log('\nSafety Backup:');
console.log(`- Location: ${result.preRestoreBackup}`);
console.log('- This backup can be used to rollback if needed');
}
}
// Show recent log entries
console.log('\nRecent Log Entries:');
result.logs.slice(-10).forEach(log => {
const icon = log.level === 'error' ? '❌' : log.level === 'warn' ? '⚠️ ' : '️ ';
console.log(`${icon} [${log.timestamp}] ${log.message}`);
});
// Clean up test files
if (options.source && options.source.includes('test-backup')) {
await fs.rmdir(path.dirname(options.source), { recursive: true }).catch(() => {});
}
} catch (error) {
console.error('\n❌ Restore operation failed:', error.message);
// Show logs if available
if (restoreService.restoreLog && restoreService.restoreLog.length > 0) {
console.log('\nError Log:');
restoreService.restoreLog.slice(-10).forEach(log => {
if (log.level === 'error' || log.level === 'warn') {
console.log(`[${log.timestamp}] ${log.level.toUpperCase()}: ${log.message}`);
}
});
}
process.exit(1);
}
// Cleanup
await db.destroy();
process.exit(0);
}
// Show help if requested
if (args.includes('--help') || args.includes('-h')) {
console.log(`
PicPeak Restore Service Test
This script tests the restore service functionality with safety checks.
Usage:
node scripts/test-restore-service.js [options]
Options:
--dry-run Perform validation only without actual restore
--force Force restore even with warnings
--type Restore type: full, database, files, selective (default: full)
--source Backup source path or S3 URL
--manifest Path to backup manifest
--help Show this help message
Examples:
# Dry run with automatic backup selection
node scripts/test-restore-service.js --dry-run
# Full restore from specific backup
node scripts/test-restore-service.js --source /backup/2024-01-20 --manifest /backup/2024-01-20/manifest.json
# Database-only restore with force
node scripts/test-restore-service.js --type database --force --source /backup/2024-01-20 --manifest /backup/2024-01-20/manifest.json
# Restore from S3
node scripts/test-restore-service.js --source s3://my-bucket/backups/2024-01-20 --manifest s3://my-bucket/backups/2024-01-20/manifest.json
Safety Features:
- Pre-restore validation checks compatibility and warns about potential issues
- Automatic pre-restore backup is created (unless skipped)
- Post-restore verification ensures data integrity
- Rollback capability if restore fails
- Detailed logging of all operations
`);
process.exit(0);
}
// Run the test
testRestore();
+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);
+71 -35
View File
@@ -4,19 +4,27 @@ 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');
const { startExpirationChecker } = require('./src/services/expirationChecker');
const { initializeTransporter, startEmailQueueProcessor } = require('./src/services/emailProcessor');
const { startBackupService } = require('./src/services/backupService');
const { startScheduledBackups } = require('./src/services/databaseBackup');
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,37 +101,23 @@ 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 with increased limits for large uploads
app.use(express.json({ limit: '100mb' }));
@@ -158,6 +152,28 @@ app.use('/thumbnails', require('./src/middleware/photoAuth'), setCorsHeaders, se
// Static file serving for uploads (public - logos, favicons)
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) => {
try {
@@ -187,6 +203,8 @@ app.use('/api/gallery', galleryRoutes);
app.use('/api/admin', adminRoutes);
app.use('/api/admin/auth', adminAuthRoutes);
app.use('/api/admin/system', require('./src/routes/adminSystem'));
app.use('/api/admin/backup', require('./src/routes/adminBackup'));
app.use('/api/admin/database-backup', require('./src/routes/adminDatabaseBackup'));
app.use('/api/public/settings', require('./src/routes/publicSettings'));
app.use('/api/public', require('./src/routes/publicCMS'));
app.use('/api/images', require('./src/routes/protectedImages'));
@@ -203,9 +221,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();
@@ -217,6 +247,12 @@ async function startServer() {
await initializeTransporter();
startEmailQueueProcessor();
// Start backup service
await startBackupService();
// Start database backup service
await startScheduledBackups();
app.listen(PORT, () => {
logger.info(`Server running on port ${PORT}`);
logger.info(`Admin interface: ${process.env.ADMIN_URL || 'http://localhost:3000'}`);
+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)
+31 -4
View File
@@ -4,6 +4,33 @@ const knexConfig = require('../../knexfile');
// Create database connection with built-in retry logic
const db = knex(knexConfig);
// Connection retry configuration
const MAX_RETRIES = 3;
const RETRY_DELAY = 1000;
// Wrapper function to handle connection retries
async function withRetry(queryFn, retries = MAX_RETRIES) {
for (let i = 0; i < retries; i++) {
try {
return await queryFn();
} catch (error) {
const isConnectionError = error.message && (
error.message.includes('Connection terminated unexpectedly') ||
error.message.includes('Connection ended unexpectedly') ||
error.message.includes('ECONNREFUSED') ||
error.message.includes('ETIMEDOUT')
);
if (isConnectionError && i < retries - 1) {
console.log(`Database connection error, retrying in ${RETRY_DELAY}ms... (attempt ${i + 1}/${retries})`);
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY * (i + 1)));
continue;
}
throw error;
}
}
}
async function initializeDatabase() {
// Events table
const hasEventsTable = await db.schema.hasTable('events');
@@ -59,9 +86,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');
@@ -225,4 +252,4 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor =
}
}
module.exports = { db, initializeDatabase, logActivity };
module.exports = { db, initializeDatabase, logActivity, withRetry };
+60 -1
View File
@@ -1,24 +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);
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' });
}
}
+10 -8
View File
@@ -1,5 +1,5 @@
const jwt = require('jsonwebtoken');
const { db } = require('../database/db');
const { db, withRetry } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
// Middleware to verify gallery access
@@ -11,13 +11,15 @@ 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: formatBoolean(true),
is_archived: formatBoolean(false)
})
.first();
const event = await withRetry(async () => {
return 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' });
+1 -1
View File
@@ -10,7 +10,7 @@ const DEFAULT_SESSION_TIMEOUT = 60 * 60 * 1000;
// Cache for session timeout setting
let cachedTimeout = null;
let cacheExpiry = 0;
const CACHE_DURATION = 5 * 60 * 1000; // 5 minutes
const CACHE_DURATION = 30 * 60 * 1000; // 30 minutes - reduced DB queries
// Clean up expired sessions every 5 minutes
setInterval(() => {
+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
};
+4
View File
@@ -11,6 +11,8 @@ const photosRoutes = require('./adminPhotos');
const categoriesRoutes = require('./adminCategories');
const cmsRoutes = require('./adminCMS');
const notificationsRoutes = require('./adminNotifications');
const backupRoutes = require('./adminBackup');
const restoreRoutes = require('./adminRestore');
// Mount sub-routers
router.use('/dashboard', dashboardRoutes);
@@ -22,5 +24,7 @@ router.use('/events', photosRoutes);
router.use('/categories', categoriesRoutes);
router.use('/cms', cmsRoutes);
router.use('/notifications', notificationsRoutes);
router.use('/backup', backupRoutes);
router.use('/restore', restoreRoutes);
module.exports = router;
+1 -1
View File
@@ -251,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;
}
+956
View File
@@ -0,0 +1,956 @@
const express = require('express');
const { db } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
const logger = require('../utils/logger');
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const archiver = require('archiver');
const S3StorageAdapter = require('../services/storage/s3Storage');
const router = express.Router();
// Get backup configuration
router.get('/config', adminAuth, async (req, res) => {
try {
const settings = await db('app_settings')
.where('setting_type', 'backup')
.select('setting_key', 'setting_value');
const config = {};
settings.forEach(setting => {
try {
config[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
config[setting.setting_key] = setting.setting_value;
}
});
res.json(config);
} catch (error) {
logger.error('Failed to get backup configuration:', error);
res.status(500).json({ error: 'Failed to get backup configuration' });
}
});
// Update backup configuration
router.put('/config', adminAuth, async (req, res) => {
try {
const updates = req.body;
// Validate required fields based on destination type
if (updates.backup_destination_type) {
switch (updates.backup_destination_type) {
case 'local':
if (!updates.backup_destination_path) {
return res.status(400).json({ error: 'Local backup requires destination path' });
}
break;
case 'rsync':
if (!updates.backup_rsync_host || !updates.backup_rsync_path) {
return res.status(400).json({ error: 'Rsync backup requires host and path' });
}
break;
case 's3':
if (!updates.backup_s3_endpoint || !updates.backup_s3_bucket ||
!updates.backup_s3_access_key || !updates.backup_s3_secret_key) {
return res.status(400).json({ error: 'S3 backup requires endpoint, bucket, and credentials' });
}
break;
}
}
// Update settings
for (const [key, value] of Object.entries(updates)) {
if (key.startsWith('backup_')) {
await db('app_settings')
.insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'backup',
updated_at: new Date()
})
.onConflict('setting_key')
.merge({
setting_value: JSON.stringify(value),
updated_at: new Date()
});
}
}
// Restart backup service if enabled status changed
if ('backup_enabled' in updates) {
const { startBackupService, stopBackupService } = require('../services/backupService');
if (updates.backup_enabled) {
await startBackupService();
} else {
stopBackupService();
}
}
res.json({ success: true, message: 'Backup configuration updated' });
} catch (error) {
logger.error('Failed to update backup configuration:', error);
res.status(500).json({ error: 'Failed to update backup configuration' });
}
});
// Get backup status and history
router.get('/status', adminAuth, async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const status = await getBackupStatus(limit);
res.json(status);
} catch (error) {
logger.error('Failed to get backup status:', error);
res.status(500).json({ error: 'Failed to get backup status' });
}
});
// Trigger manual backup
router.post('/run', adminAuth, async (req, res) => {
try {
// Check if backup is already running
const status = await getBackupStatus();
if (status.isRunning) {
return res.status(409).json({ error: 'Backup is already running' });
}
// Start backup in background
triggerManualBackup().catch(error => {
logger.error('Manual backup failed:', error);
});
res.json({ success: true, message: 'Backup started' });
} catch (error) {
logger.error('Failed to trigger manual backup:', error);
res.status(500).json({ error: 'Failed to trigger backup' });
}
});
// Get backup run details
router.get('/runs/:id', adminAuth, async (req, res) => {
try {
const { id } = req.params;
const run = await db('backup_runs')
.where('id', id)
.first();
if (!run) {
return res.status(404).json({ error: 'Backup run not found' });
}
// Parse JSON fields
if (run.statistics) {
try {
run.statistics = JSON.parse(run.statistics);
} catch (e) {
// Keep as string if parsing fails
}
}
res.json(run);
} catch (error) {
logger.error('Failed to get backup run details:', error);
res.status(500).json({ error: 'Failed to get backup run details' });
}
});
// Get file states (for debugging/monitoring)
router.get('/files', adminAuth, async (req, res) => {
try {
const { page = 1, limit = 50, search = '' } = req.query;
const offset = (page - 1) * limit;
let query = db('backup_file_states');
if (search) {
query = query.where('file_path', 'like', `%${search}%`);
}
const [files, totalCount] = await Promise.all([
query
.orderBy('last_backed_up', 'desc')
.limit(limit)
.offset(offset),
db('backup_file_states').count('* as count').first()
]);
res.json({
files,
pagination: {
page: parseInt(page),
limit: parseInt(limit),
total: totalCount.count,
pages: Math.ceil(totalCount.count / limit)
}
});
} catch (error) {
logger.error('Failed to get backup file states:', error);
res.status(500).json({ error: 'Failed to get file states' });
}
});
// Clean up old backup runs
router.delete('/cleanup', adminAuth, async (req, res) => {
try {
const { days = 30 } = req.body;
await cleanupOldBackupRuns(days);
res.json({ success: true, message: `Cleaned up backup runs older than ${days} days` });
} catch (error) {
logger.error('Failed to cleanup old backup runs:', error);
res.status(500).json({ error: 'Failed to cleanup backup runs' });
}
});
// Test backup destination connectivity
router.post('/test-connection', adminAuth, async (req, res) => {
try {
const { destination_type, ...config } = req.body;
switch (destination_type) {
case 'local':
// Test local path access
const fs = require('fs').promises;
try {
await fs.access(config.path, fs.constants.W_OK);
res.json({ success: true, message: 'Local path is writable' });
} catch (error) {
res.json({ success: false, message: 'Cannot write to local path: ' + error.message });
}
break;
case 'rsync':
// Test rsync connection
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const sshCommand = config.ssh_key
? `ssh -i ${config.ssh_key} -o StrictHostKeyChecking=no -o ConnectTimeout=10`
: 'ssh -o StrictHostKeyChecking=no -o ConnectTimeout=10';
const testCommand = config.user
? `${sshCommand} ${config.user}@${config.host} "echo 'Connection successful'"`
: `${sshCommand} ${config.host} "echo 'Connection successful'"`;
try {
const { stdout } = await execAsync(testCommand);
res.json({ success: true, message: 'Rsync connection successful' });
} catch (error) {
res.json({ success: false, message: 'Rsync connection failed: ' + error.message });
}
break;
case 's3':
// Test S3 connection (would need AWS SDK)
res.json({ success: false, message: 'S3 testing not implemented yet' });
break;
default:
res.status(400).json({ error: 'Invalid destination type' });
}
} catch (error) {
logger.error('Failed to test backup connection:', error);
res.status(500).json({ error: 'Failed to test connection' });
}
});
// Get backup manifest for a specific backup run
router.get('/manifest/:backupRunId', adminAuth, async (req, res) => {
try {
const { backupRunId } = req.params;
const result = await getBackupManifest(backupRunId);
res.json({
backupRunId,
manifest: result.manifest,
summary: result.summary
});
} catch (error) {
logger.error('Failed to get backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' });
}
});
// Validate a backup manifest
router.post('/manifest/validate', adminAuth, async (req, res) => {
try {
const { manifestPath } = req.body;
if (!manifestPath) {
return res.status(400).json({ error: 'manifestPath is required' });
}
const result = await validateBackupManifest(manifestPath);
res.json({
valid: result.valid,
error: result.error,
manifestPath
});
} catch (error) {
logger.error('Failed to validate manifest:', error);
res.status(500).json({ error: 'Failed to validate manifest' });
}
});
// Download backup manifest
router.get('/manifest/:backupRunId/download', adminAuth, async (req, res) => {
try {
const { backupRunId } = req.params;
const { format = 'json' } = req.query;
const result = await getBackupManifest(backupRunId);
// Set appropriate headers
const filename = `backup-manifest-${backupRunId}.${format}`;
res.setHeader('Content-Type', format === 'yaml' ? 'text/yaml' : 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
// Send the manifest in requested format
if (format === 'yaml') {
const yaml = require('js-yaml');
res.send(yaml.dump(result.manifest, {
indent: 2,
lineWidth: -1,
noRefs: true,
sortKeys: true
}));
} else {
res.json(result.manifest);
}
} catch (error) {
logger.error('Failed to download backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' });
}
});
// Get manifest for specific backup
router.get('/manifests/:backupId', adminAuth, async (req, res) => {
try {
const { backupId } = req.params;
const result = await getBackupManifest(backupId);
res.json({
backupId,
manifest: result.manifest,
summary: result.summary
});
} catch (error) {
logger.error('Failed to get backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' });
}
});
// Download manifest file
router.get('/manifests/:backupId/download', adminAuth, async (req, res) => {
try {
const { backupId } = req.params;
const { format = 'json' } = req.query;
const result = await getBackupManifest(backupId);
// Set appropriate headers
const filename = `backup-manifest-${backupId}.${format}`;
res.setHeader('Content-Type', format === 'yaml' ? 'text/yaml' : 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
// Send the manifest in requested format
if (format === 'yaml') {
const yaml = require('js-yaml');
res.send(yaml.dump(result.manifest, {
indent: 2,
lineWidth: -1,
noRefs: true,
sortKeys: true
}));
} else {
res.json(result.manifest);
}
} catch (error) {
logger.error('Failed to download backup manifest:', error);
res.status(404).json({ error: error.message || 'Backup manifest not found' });
}
});
// Validate a manifest
router.post('/manifests/validate', adminAuth, async (req, res) => {
try {
const { manifestPath, manifestData } = req.body;
if (!manifestPath && !manifestData) {
return res.status(400).json({ error: 'Either manifestPath or manifestData is required' });
}
if (manifestData) {
// Validate provided manifest data directly
const validationResult = await validateManifestData(manifestData);
return res.json(validationResult);
}
// Use existing validation function for path
const result = await validateBackupManifest(manifestPath);
res.json({
valid: result.valid,
error: result.error,
manifestPath
});
} catch (error) {
logger.error('Failed to validate manifest:', error);
res.status(500).json({ error: 'Failed to validate manifest' });
}
});
// List S3 buckets
router.get('/s3/buckets', adminAuth, async (req, res) => {
try {
const config = await getBackupConfig();
if (config.backup_destination_type !== 's3') {
return res.status(400).json({ error: 'S3 backup not configured' });
}
const s3Adapter = new S3StorageAdapter({
endpoint: config.backup_s3_endpoint,
bucket: config.backup_s3_bucket,
accessKeyId: config.backup_s3_access_key,
secretAccessKey: config.backup_s3_secret_key,
region: config.backup_s3_region || 'us-east-1',
forcePathStyle: config.backup_s3_force_path_style || false
});
// List buckets using the S3 client
const { ListBucketsCommand } = require('@aws-sdk/client-s3');
const result = await s3Adapter.s3Client.send(new ListBucketsCommand({}));
res.json({
buckets: result.Buckets || [],
owner: result.Owner || null
});
} catch (error) {
logger.error('Failed to list S3 buckets:', error);
res.status(500).json({ error: 'Failed to list S3 buckets: ' + error.message });
}
});
// List files in S3 backup location
router.get('/s3/files', adminAuth, async (req, res) => {
try {
const { prefix = '', maxKeys = 100, continuationToken } = req.query;
const config = await getBackupConfig();
if (config.backup_destination_type !== 's3') {
return res.status(400).json({ error: 'S3 backup not configured' });
}
const s3Adapter = new S3StorageAdapter({
endpoint: config.backup_s3_endpoint,
bucket: config.backup_s3_bucket,
accessKeyId: config.backup_s3_access_key,
secretAccessKey: config.backup_s3_secret_key,
region: config.backup_s3_region || 'us-east-1',
forcePathStyle: config.backup_s3_force_path_style || false
});
const result = await s3Adapter.list(prefix, {
maxKeys: parseInt(maxKeys),
continuationToken
});
res.json({
files: result.objects || [],
directories: result.directories || [],
isTruncated: result.isTruncated,
nextContinuationToken: result.nextContinuationToken,
prefix: prefix
});
} catch (error) {
logger.error('Failed to list S3 files:', error);
res.status(500).json({ error: 'Failed to list S3 files: ' + error.message });
}
});
// Clean up old S3 backups
router.delete('/s3/cleanup', adminAuth, async (req, res) => {
try {
const { retentionDays = 30, dryRun = false } = req.body;
const config = await getBackupConfig();
if (config.backup_destination_type !== 's3') {
return res.status(400).json({ error: 'S3 backup not configured' });
}
const s3Adapter = new S3StorageAdapter({
endpoint: config.backup_s3_endpoint,
bucket: config.backup_s3_bucket,
accessKeyId: config.backup_s3_access_key,
secretAccessKey: config.backup_s3_secret_key,
region: config.backup_s3_region || 'us-east-1',
forcePathStyle: config.backup_s3_force_path_style || false
});
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
// List all backup files
const backupFiles = await s3Adapter.list('backups/', { maxKeys: 1000 });
const filesToDelete = [];
let totalSize = 0;
for (const file of backupFiles.objects || []) {
if (file.lastModified && new Date(file.lastModified) < cutoffDate) {
filesToDelete.push(file.key);
totalSize += file.size || 0;
}
}
if (dryRun) {
return res.json({
wouldDelete: filesToDelete.length,
totalSize: totalSize,
files: filesToDelete.slice(0, 100), // Limit preview
message: 'Dry run completed - no files were deleted'
});
}
// Delete files in batches
const deleteResult = await s3Adapter.deleteMany(filesToDelete);
const deletedCount = deleteResult.Deleted ? deleteResult.Deleted.length : 0;
// Also clean up database records
await cleanupOldBackupRuns(retentionDays);
res.json({
success: true,
deletedCount: deletedCount,
totalSize: totalSize,
message: `Cleaned up ${deletedCount} S3 backup files older than ${retentionDays} days`
});
} catch (error) {
logger.error('Failed to cleanup S3 backups:', error);
res.status(500).json({ error: 'Failed to cleanup S3 backups: ' + error.message });
}
});
// Test S3 upload functionality
router.post('/s3/test-upload', adminAuth, async (req, res) => {
try {
const config = await getBackupConfig();
if (config.backup_destination_type !== 's3') {
return res.status(400).json({ error: 'S3 backup not configured' });
}
const s3Adapter = new S3StorageAdapter({
endpoint: config.backup_s3_endpoint,
bucket: config.backup_s3_bucket,
accessKeyId: config.backup_s3_access_key,
secretAccessKey: config.backup_s3_secret_key,
region: config.backup_s3_region || 'us-east-1',
forcePathStyle: config.backup_s3_force_path_style || false
});
// Create test content
const testKey = `test/backup-test-${Date.now()}.txt`;
const testContent = `PicPeak S3 backup test\nTimestamp: ${new Date().toISOString()}\nEndpoint: ${config.backup_s3_endpoint || 'AWS'}\nBucket: ${config.backup_s3_bucket}`;
// Test upload
const uploadStart = Date.now();
await s3Adapter.upload(testKey, Buffer.from(testContent));
const uploadTime = Date.now() - uploadStart;
// Test download
const downloadStart = Date.now();
const downloadedContent = await s3Adapter.download(testKey);
const downloadTime = Date.now() - downloadStart;
// Verify content
const contentMatch = downloadedContent.toString() === testContent;
// Test deletion
await s3Adapter.delete(testKey);
res.json({
success: true,
testKey: testKey,
uploadTime: uploadTime,
downloadTime: downloadTime,
contentMatch: contentMatch,
message: 'S3 upload test completed successfully'
});
} catch (error) {
logger.error('S3 upload test failed:', error);
res.status(500).json({ error: 'S3 upload test failed: ' + error.message });
}
});
// Download entire backup
router.get('/download/:backupId', adminAuth, async (req, res) => {
try {
const { backupId } = req.params;
// Get backup run details
const backupRun = await db('backup_runs')
.where('id', backupId)
.first();
if (!backupRun) {
return res.status(404).json({ error: 'Backup not found' });
}
if (backupRun.status !== 'completed') {
return res.status(400).json({ error: 'Backup is not completed' });
}
const config = await getBackupConfig();
// Handle different backup types
switch (config.backup_destination_type) {
case 'local':
// Stream local backup as zip
const backupPath = path.join(config.backup_destination_path, `backup-${backupRun.id}`);
const archive = archiver('zip', { zlib: { level: 9 } });
res.attachment(`picpeak-backup-${backupRun.id}.zip`);
archive.pipe(res);
// Add backup directory contents
archive.directory(backupPath, false);
// Add manifest if exists
if (backupRun.manifest_path && await fs.access(backupRun.manifest_path).then(() => true).catch(() => false)) {
archive.file(backupRun.manifest_path, { name: 'manifest.json' });
}
await archive.finalize();
break;
case 's3':
// For S3, provide pre-signed URLs or stream files
const s3Adapter = new S3StorageAdapter({
endpoint: config.backup_s3_endpoint,
bucket: config.backup_s3_bucket,
accessKeyId: config.backup_s3_access_key,
secretAccessKey: config.backup_s3_secret_key,
region: config.backup_s3_region || 'us-east-1',
forcePathStyle: config.backup_s3_force_path_style || false
});
// List all files for this backup
const prefix = `backups/${backupRun.id}/`;
const files = await s3Adapter.list(prefix, { maxKeys: 1000 });
// Generate pre-signed URLs
const urls = [];
for (const file of files.objects || []) {
const url = await s3Adapter.getSignedUrl('getObject', file.key, { expiresIn: 3600 }); // 1 hour
urls.push({
key: file.key,
size: file.size,
url: url
});
}
res.json({
backupId: backupRun.id,
type: 's3',
files: urls,
expiresIn: 3600,
message: 'Use the provided URLs to download individual files'
});
break;
case 'rsync':
return res.status(400).json({ error: 'Direct download not available for rsync backups' });
default:
return res.status(400).json({ error: 'Unknown backup type' });
}
} catch (error) {
logger.error('Failed to download backup:', error);
res.status(500).json({ error: 'Failed to download backup: ' + error.message });
}
});
// Get current file checksums
router.get('/checksums', adminAuth, async (req, res) => {
try {
const { path: targetPath = '', recursive = true } = req.query;
const checksums = {};
// Get storage path
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const basePath = targetPath ? path.join(storagePath, targetPath) : storagePath;
// Calculate checksums for files
async function calculateDirChecksums(dirPath, relative = '') {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const relativePath = path.join(relative, entry.name);
if (entry.isDirectory() && recursive) {
await calculateDirChecksums(fullPath, relativePath);
} else if (entry.isFile()) {
const hash = crypto.createHash('sha256');
const stream = require('fs').createReadStream(fullPath);
await new Promise((resolve, reject) => {
stream.on('data', data => hash.update(data));
stream.on('end', () => {
checksums[relativePath] = {
checksum: hash.digest('hex'),
size: entry.size,
modified: entry.mtime
};
resolve();
});
stream.on('error', reject);
});
}
}
} catch (error) {
logger.error(`Failed to calculate checksums for ${dirPath}:`, error);
}
}
await calculateDirChecksums(basePath);
// Also get database checksums from backup_file_states
const dbChecksums = await db('backup_file_states')
.select('file_path', 'checksum', 'size_bytes', 'last_modified');
res.json({
currentChecksums: checksums,
totalFiles: Object.keys(checksums).length,
databaseChecksums: dbChecksums.reduce((acc, row) => {
acc[row.file_path] = {
checksum: row.checksum,
size: row.size_bytes,
modified: row.last_modified
};
return acc;
}, {}),
path: targetPath || '/'
});
} catch (error) {
logger.error('Failed to get file checksums:', error);
res.status(500).json({ error: 'Failed to get file checksums: ' + error.message });
}
});
// Estimate backup size before running
router.post('/estimate', adminAuth, async (req, res) => {
try {
const { includeArchived = true } = req.body;
// Get storage path
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
let totalSize = 0;
let fileCount = 0;
const breakdown = {};
// Estimate size for each directory
async function estimateDir(dirPath, category) {
let dirSize = 0;
let dirCount = 0;
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isDirectory()) {
const subResult = await estimateDir(fullPath, category);
dirSize += subResult.size;
dirCount += subResult.count;
} else if (entry.isFile()) {
const stats = await fs.stat(fullPath);
dirSize += stats.size;
dirCount++;
}
}
} catch (error) {
if (error.code !== 'ENOENT') {
logger.error(`Failed to estimate ${dirPath}:`, error);
}
}
return { size: dirSize, count: dirCount };
}
// Estimate each category
const categories = [
{ path: 'events/active', name: 'Active Events' },
{ path: 'thumbnails', name: 'Thumbnails' },
{ path: 'uploads', name: 'Uploads' }
];
if (includeArchived) {
categories.push({ path: 'events/archived', name: 'Archived Events' });
}
for (const category of categories) {
const result = await estimateDir(path.join(storagePath, category.path), category.name);
breakdown[category.name] = {
size: result.size,
sizeFormatted: formatBytes(result.size),
fileCount: result.count
};
totalSize += result.size;
fileCount += result.count;
}
// Estimate database size
const dbPath = process.env.DB_TYPE === 'postgresql'
? null
: path.join(__dirname, '../../database.sqlite');
if (dbPath) {
try {
const dbStats = await fs.stat(dbPath);
breakdown['Database'] = {
size: dbStats.size,
sizeFormatted: formatBytes(dbStats.size),
fileCount: 1
};
totalSize += dbStats.size;
fileCount += 1;
} catch (error) {
logger.error('Failed to get database size:', error);
}
}
// Estimate compression ratio (typically 20-40% for mixed media)
const estimatedCompressedSize = Math.round(totalSize * 0.7);
res.json({
totalSize: totalSize,
totalSizeFormatted: formatBytes(totalSize),
estimatedCompressedSize: estimatedCompressedSize,
estimatedCompressedSizeFormatted: formatBytes(estimatedCompressedSize),
fileCount: fileCount,
breakdown: breakdown,
includeArchived: includeArchived,
estimatedDuration: Math.max(60, Math.round(totalSize / (50 * 1024 * 1024))), // Estimate 50MB/s
warnings: totalSize > 10 * 1024 * 1024 * 1024 ? ['Backup size exceeds 10GB, may take significant time'] : []
});
} catch (error) {
logger.error('Failed to estimate backup size:', error);
res.status(500).json({ error: 'Failed to estimate backup size: ' + error.message });
}
});
// Helper function to format bytes
function formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
// Helper function to get backup configuration
async function getBackupConfig() {
try {
const settings = await db('app_settings')
.where('setting_type', 'backup')
.select('setting_key', 'setting_value');
const config = {};
settings.forEach(setting => {
try {
config[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
config[setting.setting_key] = setting.setting_value;
}
});
return config;
} catch (error) {
logger.error('Failed to get backup configuration:', error);
return {};
}
}
// Helper function to validate manifest data
async function validateManifestData(manifestData) {
try {
// Check required fields
const requiredFields = ['version', 'backupId', 'timestamp', 'files'];
const missingFields = requiredFields.filter(field => !manifestData[field]);
if (missingFields.length > 0) {
return {
valid: false,
error: `Missing required fields: ${missingFields.join(', ')}`,
details: { missingFields }
};
}
// Validate version
if (manifestData.version !== '1.0') {
return {
valid: false,
error: `Unsupported manifest version: ${manifestData.version}`,
details: { version: manifestData.version }
};
}
// Validate files array
if (!Array.isArray(manifestData.files)) {
return {
valid: false,
error: 'Files must be an array',
details: { filesType: typeof manifestData.files }
};
}
// Validate each file entry
const invalidFiles = [];
for (let i = 0; i < manifestData.files.length; i++) {
const file = manifestData.files[i];
if (!file.path || !file.checksum || typeof file.size !== 'number') {
invalidFiles.push({ index: i, file });
}
}
if (invalidFiles.length > 0) {
return {
valid: false,
error: `Invalid file entries: ${invalidFiles.length}`,
details: { invalidFiles: invalidFiles.slice(0, 10) } // Limit to first 10
};
}
return {
valid: true,
details: {
version: manifestData.version,
backupId: manifestData.backupId,
timestamp: manifestData.timestamp,
fileCount: manifestData.files.length,
totalSize: manifestData.files.reduce((sum, f) => sum + (f.size || 0), 0)
}
};
} catch (error) {
return {
valid: false,
error: `Validation error: ${error.message}`,
details: { error: error.message }
};
}
}
module.exports = router;
+35 -11
View File
@@ -48,9 +48,9 @@ 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();
@@ -73,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')
@@ -243,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)');
@@ -272,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);
@@ -309,10 +310,33 @@ router.get('/analytics', adminAuth, async (req, res) => {
devices[d.device_type] = Math.round((d.count / totalDevices) * 100);
});
// Calculate totals for the period (matching /stats logic)
const totalViews = await db('access_logs')
.where('action', 'view')
.where('timestamp', '>=', startDateStr)
.count('id as count')
.first();
const totalDownloadsCount = await db('access_logs')
.whereIn('action', ['download', 'download_all'])
.where('timestamp', '>=', startDateStr)
.count('id as count')
.first();
const totalUniqueVisitors = await db('access_logs')
.where('timestamp', '>=', startDateStr)
.countDistinct('ip_address as count')
.first();
res.json({
chartData: dates,
topGalleries,
devices
devices,
totals: {
views: totalViews?.count || 0,
downloads: totalDownloadsCount?.count || 0,
uniqueVisitors: totalUniqueVisitors?.count || 0
}
});
} catch (error) {
console.error('Analytics error:', error);
+273
View File
@@ -0,0 +1,273 @@
const express = require('express');
const router = express.Router();
const { adminAuth } = require('../middleware/auth');
const { databaseBackupService } = require('../services/databaseBackup');
const { db } = require('../database/db');
const logger = require('../utils/logger');
// All routes require admin authentication
router.use(adminAuth);
/**
* Get database backup status and configuration
*/
router.get('/status', async (req, res) => {
try {
// Get configuration
const config = await databaseBackupService.getBackupConfig();
// Get recent backup history
const history = await databaseBackupService.getBackupHistory(10);
// Get current progress if running
const progress = databaseBackupService.getProgress();
// Calculate health status
const lastBackup = history[0];
const isHealthy = lastBackup && lastBackup.status === 'completed' &&
new Date(lastBackup.completed_at) > new Date(Date.now() - 48 * 60 * 60 * 1000); // Within 48 hours
res.json({
config,
isRunning: databaseBackupService.isRunning,
isHealthy,
currentProgress: progress,
lastBackup,
recentBackups: history,
dbType: databaseBackupService.dbType
});
} catch (error) {
logger.error('Failed to get database backup status:', error);
res.status(500).json({ error: 'Failed to get backup status' });
}
});
/**
* Update database backup configuration
*/
router.put('/config', async (req, res) => {
try {
const allowedSettings = [
'database_backup_enabled',
'database_backup_schedule',
'database_backup_destination_path',
'database_backup_compress',
'database_backup_validate_integrity',
'database_backup_include_checksums',
'database_backup_retention_days',
'database_backup_email_on_failure',
'database_backup_email_on_success'
];
const updates = [];
for (const [key, value] of Object.entries(req.body)) {
if (allowedSettings.includes(key)) {
// Check if setting exists
const existing = await db('app_settings')
.where('setting_key', key)
.first();
if (existing) {
await db('app_settings')
.where('setting_key', key)
.update({
setting_value: JSON.stringify(value),
updated_at: new Date()
});
} else {
await db('app_settings').insert({
setting_key: key,
setting_value: JSON.stringify(value),
setting_type: 'database_backup'
});
}
updates.push(key);
}
}
// Restart scheduled backups if enabled state changed
if (updates.includes('database_backup_enabled') || updates.includes('database_backup_schedule')) {
const { startScheduledBackups, stopScheduledBackups } = require('../services/databaseBackup');
stopScheduledBackups();
await startScheduledBackups();
}
res.json({
success: true,
updatedSettings: updates,
message: 'Database backup configuration updated successfully'
});
} catch (error) {
logger.error('Failed to update database backup config:', error);
res.status(500).json({ error: 'Failed to update configuration' });
}
});
/**
* Trigger manual database backup
*/
router.post('/backup', async (req, res) => {
try {
if (databaseBackupService.isRunning) {
return res.status(409).json({ error: 'Backup already in progress' });
}
// Start backup asynchronously
res.json({
success: true,
message: 'Database backup started',
trackingUrl: '/api/admin/database-backup/progress'
});
// Run backup in background
databaseBackupService.backup(req.body).catch(error => {
logger.error('Manual database backup failed:', error);
});
} catch (error) {
logger.error('Failed to start database backup:', error);
res.status(500).json({ error: 'Failed to start backup' });
}
});
/**
* Get current backup progress
*/
router.get('/progress', async (req, res) => {
try {
const progress = databaseBackupService.getProgress();
res.json({
isRunning: databaseBackupService.isRunning,
progress
});
} catch (error) {
logger.error('Failed to get backup progress:', error);
res.status(500).json({ error: 'Failed to get progress' });
}
});
/**
* Get backup history with pagination
*/
router.get('/history', async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 20;
const offset = (page - 1) * limit;
const [backups, totalCount] = await Promise.all([
db('database_backup_runs')
.orderBy('started_at', 'desc')
.limit(limit)
.offset(offset),
db('database_backup_runs').count('* as count').first()
]);
res.json({
backups,
pagination: {
page,
limit,
total: totalCount.count,
pages: Math.ceil(totalCount.count / limit)
}
});
} catch (error) {
logger.error('Failed to get backup history:', error);
res.status(500).json({ error: 'Failed to get history' });
}
});
/**
* Delete old backup files
*/
router.delete('/cleanup', async (req, res) => {
try {
const { retentionDays = 30 } = req.body;
await databaseBackupService.cleanupOldBackups(retentionDays);
res.json({
success: true,
message: `Cleaned up backups older than ${retentionDays} days`
});
} catch (error) {
logger.error('Failed to cleanup old backups:', error);
res.status(500).json({ error: 'Failed to cleanup backups' });
}
});
/**
* Test database backup configuration
*/
router.post('/test', async (req, res) => {
try {
const config = await databaseBackupService.getBackupConfig();
// Test database connection
const testResults = {
databaseConnection: false,
destinationWritable: false,
compressionAvailable: true,
estimatedSize: null
};
// Test database connection
try {
await db.raw('SELECT 1');
testResults.databaseConnection = true;
} catch (error) {
testResults.databaseConnectionError = error.message;
}
// Test destination path
if (config.destinationPath) {
try {
const fs = require('fs').promises;
const testFile = `${config.destinationPath}/.test-${Date.now()}`;
await fs.writeFile(testFile, 'test');
await fs.unlink(testFile);
testResults.destinationWritable = true;
} catch (error) {
testResults.destinationError = error.message;
}
}
// Estimate database size
try {
testResults.estimatedSize = await databaseBackupService.getDatabaseSize();
} catch (error) {
testResults.sizeError = error.message;
}
res.json({
success: testResults.databaseConnection && testResults.destinationWritable,
results: testResults
});
} catch (error) {
logger.error('Failed to test backup configuration:', error);
res.status(500).json({ error: 'Failed to test configuration' });
}
});
/**
* Get table checksums
*/
router.get('/checksums', async (req, res) => {
try {
const checksums = await databaseBackupService.getTableChecksums();
res.json({
checksums,
tableCount: Object.keys(checksums).length,
totalRows: Object.values(checksums).reduce((sum, table) => sum + table.rowCount, 0)
});
} catch (error) {
logger.error('Failed to get table checksums:', error);
res.status(500).json({ error: 'Failed to get checksums' });
}
});
module.exports = router;
+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
};
+36 -34
View File
@@ -10,7 +10,7 @@ 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');
@@ -89,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
@@ -128,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,
@@ -138,10 +144,10 @@ 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',
@@ -546,10 +552,10 @@ router.post('/:id/reset-password', adminAuth, async (req, res) => {
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
});
}
@@ -578,41 +584,37 @@ router.post('/:id/resend-email', adminAuth, async (req, res) => {
return res.status(404).json({ error: 'Event not found' });
}
// Get the language preference
let language = 'en';
try {
// First check app_settings for general_default_language
const langSetting = await db('app_settings')
.where('setting_key', 'general_default_language')
.first();
if (langSetting && langSetting.setting_value) {
language = langSetting.setting_value;
}
} catch (err) {
console.warn('Could not fetch language setting:', err);
// 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}}';
}
// Format dates based on language
const eventDate = new Date(event.event_date);
const expiryDate = new Date(event.expires_at);
const dateLocale = language === 'de' ? 'de-DE' : 'en-US';
// Prepare password text based on language
const passwordText = language === 'de'
? '(Aus Sicherheitsgründen nicht angezeigt)'
: '(Not shown for security reasons)';
// 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: eventDate.toLocaleDateString(dateLocale),
event_date: event.event_date, // Pass raw date - will be formatted by email processor
gallery_link: event.share_link,
gallery_password: passwordText,
expiry_date: expiryDate.toLocaleDateString(dateLocale),
gallery_password: galleryPassword,
expiry_date: event.expires_at, // Pass raw date - will be formatted by email processor
welcome_message: event.welcome_message || '',
eventId: id
eventId: id,
isResend: true // Flag to indicate this is a resend
});
// Log the activity using the proper schema
+233 -107
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);
}
});
@@ -65,6 +51,9 @@ const upload = multer({
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
@@ -75,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');
@@ -87,9 +78,29 @@ 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
// Increased limit to 500 files, but recommend chunked uploads for better performance
router.post('/:eventId/upload', adminAuth, (req, res, next) => {
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);
@@ -106,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;
@@ -121,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' });
}
@@ -138,15 +165,27 @@ 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' });
}
}
// Create final destination directory
const finalDestPath = path.join(getStoragePath(), 'events/active', event.slug);
await fs.mkdir(finalDestPath, { recursive: true });
const uploadedPhotos = [];
const errors = [];
// Process files in batches to optimize database operations
const BATCH_SIZE = 10; // Process 10 files at a time for 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);
@@ -169,16 +208,25 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
.whereNull('category_id')
.count('id as count')
.first();
batchCounter = (uncategorizedCount.count || 0) + 1;
batchCounter = (parseInt(uncategorizedCount.count) || 0) + 1;
}
const batchPhotos = [];
const fileRenameOperations = []; // Store rename operations to do after commit
// 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(
@@ -188,77 +236,142 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
extension
);
// Rename the file
const oldPath = file.path;
const newPath = path.join(path.dirname(oldPath), newFilename);
await fs.rename(oldPath, newPath);
// Update file object
file.filename = newFilename;
file.path = newPath;
// Generate thumbnail with new filename
const thumbnailPath = await generateThumbnail(file.path);
// Calculate relative paths
// Calculate final path
const finalPath = path.join(finalDestPath, newFilename);
const storagePath = getStoragePath();
const relativePath = path.relative(path.join(storagePath, 'events/active'), file.path);
const relativeThumbPath = thumbnailPath;
const relativePath = path.relative(path.join(storagePath, 'events/active'), finalPath);
// Prepare photo data for batch insert
batchPhotos.push({
event_id: eventId,
filename: file.filename,
const photoData = {
event_id: parseInt(eventId),
filename: newFilename,
path: relativePath,
thumbnail_path: relativeThumbPath,
category_id: parsedCategoryId || null,
thumbnail_path: null, // Will generate after successful commit
category_id: parsedCategoryId ? parseInt(parsedCategoryId) : null,
type: 'individual',
size_bytes: file.size
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 processing file ${file.originalname}:`, error);
console.error(`Error preparing file ${file.originalname}:`, error);
errors.push({ filename: file.originalname, error: error.message });
// Delete the file if it was partially processed
if (file.path) {
try { await fs.unlink(file.path); } catch (e) {}
}
}
}
// Batch insert all photos from this batch
// 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) {
if (category && parsedCategoryId) {
const newCounter = batchCounter + batchPhotos.length - 1;
await trx('photo_categories')
.where({ id: parsedCategoryId })
.update({ photo_counter: batchCounter + batchPhotos.length - 1 });
.update({ photo_counter: newCounter });
console.log(`Updated category ${parsedCategoryId} counter to ${newCounter}`);
}
// Add to uploaded photos array
batchPhotos.forEach((photo, index) => {
uploadedPhotos.push({
id: insertedIds[index]?.id || insertedIds[index],
filename: photo.filename,
size: photo.size_bytes,
category_id: photo.category_id
});
});
// 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();
}
// Commit the batch transaction
await trx.commit();
} catch (error) {
console.error(`Error processing batch starting at index ${i}:`, error);
await trx.rollback();
console.error('Stack trace:', error.stack);
// Try to clean up files from failed batch
for (const file of batch) {
if (file.path) {
try { await fs.unlink(file.path); } catch (e) {}
}
// 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);
}
}
@@ -269,24 +382,39 @@ router.post('/:eventId/upload', adminAuth, (req, res, next) => {
{ type: 'admin', id: req.admin.id, name: req.admin.username }
);
// 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,
totalFiles: req.files.length,
totalFiles: totalAttempted,
successCount: uploadedPhotos.length,
failureCount: errors.length
failureCount: totalInvalidFiles.length
};
// Include error details if any files failed
if (errors.length > 0) {
response.errors = errors;
response.message = `Uploaded ${uploadedPhotos.length} of ${req.files.length} photos. ${errors.length} failed.`;
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' });
}
});
@@ -613,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');
+461
View File
@@ -0,0 +1,461 @@
const express = require('express');
const router = express.Router();
const { restoreService } = require('../services/restoreService');
const { adminAuth } = require('../middleware/auth');
const { body, query, validationResult } = require('express-validator');
const logger = require('../utils/logger');
const { db } = require('../database/db');
const path = require('path');
const fs = require('fs').promises;
/**
* Admin routes for restore operations
* All routes require admin authentication
*/
// Apply admin authentication to all routes
router.use(adminAuth);
/**
* Get restore service status and history
*/
router.get('/status', async (req, res) => {
try {
const limit = parseInt(req.query.limit) || 10;
const history = await restoreService.getRestoreHistory(limit);
const status = {
isRunning: restoreService.isRunning,
currentProgress: restoreService.getProgress(),
history: history,
settings: await getRestoreSettings()
};
res.json({
success: true,
data: status
});
} catch (error) {
logger.error('Failed to get restore status:', error);
res.status(500).json({
success: false,
error: 'Failed to get restore status'
});
}
});
/**
* Validate restore request
*/
router.post('/validate', [
body('source').notEmpty().withMessage('Backup source is required'),
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
body('selectedItems').optional().isArray(),
body('s3Config').optional().isObject()
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
// Perform dry run validation
const result = await restoreService.restore({
...req.body,
dryRun: true,
force: false
});
res.json({
success: true,
data: {
validation: result.validation,
spaceCheck: result.spaceCheck,
logs: result.logs
}
});
} catch (error) {
logger.error('Restore validation failed:', error);
res.status(400).json({
success: false,
error: error.message,
logs: restoreService.restoreLog
});
}
});
/**
* Start restore operation
*/
router.post('/start', [
body('source').notEmpty().withMessage('Backup source is required'),
body('manifestPath').notEmpty().withMessage('Manifest path is required'),
body('restoreType').isIn(['full', 'database', 'files', 'selective']).withMessage('Invalid restore type'),
body('selectedItems').optional().isArray(),
body('skipPreBackup').optional().isBoolean(),
body('force').optional().isBoolean(),
body('s3Config').optional().isObject()
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
// Check if restore is already running
if (restoreService.isRunning) {
return res.status(409).json({
success: false,
error: 'Restore operation already in progress'
});
}
// Check permissions for dangerous options
const settings = await getRestoreSettings();
if (req.body.force && !settings.restore_allow_force) {
return res.status(403).json({
success: false,
error: 'Force restore is not allowed by system settings'
});
}
if (req.body.skipPreBackup && settings.restore_require_pre_backup) {
return res.status(403).json({
success: false,
error: 'Skipping pre-restore backup is not allowed by system settings'
});
}
// Log restore attempt
logger.warn('Restore operation started', {
user: req.user.email,
ip: req.ip,
restoreType: req.body.restoreType,
source: req.body.source
});
// Start restore in background
restoreService.restore({
...req.body,
dryRun: false,
operator: {
type: 'manual',
userId: req.user.id,
ip: req.ip
}
}).catch(error => {
logger.error('Background restore failed:', error);
});
res.json({
success: true,
message: 'Restore operation started'
});
} catch (error) {
logger.error('Failed to start restore:', error);
res.status(500).json({
success: false,
error: error.message
});
}
});
/**
* Get current restore progress
*/
router.get('/progress', async (req, res) => {
try {
const progress = restoreService.getProgress();
const logs = restoreService.restoreLog.slice(-50); // Last 50 log entries
res.json({
success: true,
data: {
isRunning: restoreService.isRunning,
progress: progress,
logs: logs
}
});
} catch (error) {
logger.error('Failed to get restore progress:', error);
res.status(500).json({
success: false,
error: 'Failed to get restore progress'
});
}
});
/**
* Get restore run details
*/
router.get('/run/:id', async (req, res) => {
try {
const run = await db('restore_runs')
.where('id', req.params.id)
.first();
if (!run) {
return res.status(404).json({
success: false,
error: 'Restore run not found'
});
}
// Parse JSON fields
if (run.statistics) run.statistics = JSON.parse(run.statistics);
if (run.restore_log) run.restore_log = JSON.parse(run.restore_log);
if (run.metadata) run.metadata = JSON.parse(run.metadata);
// Get validation results
const validations = await db('restore_validation_results')
.where('restore_run_id', run.id)
.select('*');
validations.forEach(v => {
if (v.errors) v.errors = JSON.parse(v.errors);
if (v.warnings) v.warnings = JSON.parse(v.warnings);
if (v.checksums) v.checksums = JSON.parse(v.checksums);
});
// Get file operations summary
const fileOps = await db('restore_file_operations')
.where('restore_run_id', run.id)
.select('status', db.raw('COUNT(*) as count'))
.groupBy('status');
res.json({
success: true,
data: {
run: run,
validations: validations,
fileOperations: fileOps
}
});
} catch (error) {
logger.error('Failed to get restore run details:', error);
res.status(500).json({
success: false,
error: 'Failed to get restore run details'
});
}
});
/**
* Get restore run report
*/
router.get('/run/:id/report', async (req, res) => {
try {
const run = await db('restore_runs')
.where('id', req.params.id)
.first();
if (!run) {
return res.status(404).json({
success: false,
error: 'Restore run not found'
});
}
// Parse JSON fields
if (run.statistics) run.statistics = JSON.parse(run.statistics);
if (run.restore_log) run.restore_log = JSON.parse(run.restore_log);
// Generate report
const report = restoreService.generateRestoreReport({
success: run.status === 'completed',
duration: run.duration_seconds,
dryRun: run.is_dry_run,
result: run.statistics,
logs: run.restore_log || []
});
res.type('text/plain').send(report);
} catch (error) {
logger.error('Failed to generate restore report:', error);
res.status(500).json({
success: false,
error: 'Failed to generate restore report'
});
}
});
/**
* List available backups for restore
*/
router.get('/available-backups', async (req, res) => {
try {
const backups = [];
// Get local file backups
const backupConfig = await getBackupConfig();
if (backupConfig.backup_destination_type === 'local' && backupConfig.backup_destination_path) {
try {
const files = await fs.readdir(backupConfig.backup_destination_path);
for (const file of files) {
if (file.endsWith('.json') || file.endsWith('.yaml')) {
const filePath = path.join(backupConfig.backup_destination_path, file);
const stats = await fs.stat(filePath);
backups.push({
type: 'local',
name: file,
path: filePath,
size: stats.size,
modified: stats.mtime
});
}
}
} catch (error) {
logger.warn('Failed to list local backups:', error);
}
}
// Get database backups from backup_runs table
const backupRuns = await db('backup_runs')
.where('status', 'completed')
.whereNotNull('manifest_path')
.orderBy('completed_at', 'desc')
.limit(20);
for (const run of backupRuns) {
backups.push({
type: run.manifest_path.startsWith('s3://') ? 's3' : 'local',
name: `Backup ${run.completed_at}`,
path: run.manifest_path,
manifestId: run.manifest_id,
size: run.total_size_bytes,
filesCount: run.files_backed_up,
duration: run.duration_seconds,
completed: run.completed_at
});
}
res.json({
success: true,
data: backups
});
} catch (error) {
logger.error('Failed to list available backups:', error);
res.status(500).json({
success: false,
error: 'Failed to list available backups'
});
}
});
/**
* Get restore settings
*/
router.get('/settings', async (req, res) => {
try {
const settings = await getRestoreSettings();
res.json({
success: true,
data: settings
});
} catch (error) {
logger.error('Failed to get restore settings:', error);
res.status(500).json({
success: false,
error: 'Failed to get restore settings'
});
}
});
/**
* Update restore settings
*/
router.put('/settings', [
body('restore_allow_force').optional().isBoolean(),
body('restore_require_pre_backup').optional().isBoolean(),
body('restore_max_file_size_mb').optional().isInt({ min: 1 }),
body('restore_verify_checksums').optional().isBoolean(),
body('restore_email_on_completion').optional().isBoolean(),
body('restore_retention_days').optional().isInt({ min: 1 })
], async (req, res) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
try {
// Update settings
for (const [key, value] of Object.entries(req.body)) {
await db('app_settings')
.where('setting_key', key)
.where('setting_type', 'restore')
.update({
setting_value: typeof value === 'boolean' ? (value ? '1' : '0') : value.toString(),
updated_at: db.fn.now()
});
}
logger.info('Restore settings updated', {
user: req.user.email,
settings: req.body
});
res.json({
success: true,
message: 'Settings updated successfully'
});
} catch (error) {
logger.error('Failed to update restore settings:', error);
res.status(500).json({
success: false,
error: 'Failed to update settings'
});
}
});
/**
* Helper function to get restore settings
*/
async function getRestoreSettings() {
const settings = await db('app_settings')
.where('setting_type', 'restore')
.select('setting_key', 'setting_value');
const result = {};
settings.forEach(setting => {
// Convert boolean strings to actual booleans
if (setting.setting_value === '1' || setting.setting_value === '0') {
result[setting.setting_key] = setting.setting_value === '1';
} else {
result[setting.setting_key] = setting.setting_value;
}
});
return result;
}
/**
* Helper function to get backup configuration
*/
async function getBackupConfig() {
const settings = await db('app_settings')
.where('setting_type', 'backup')
.select('setting_key', 'setting_value');
const config = {};
settings.forEach(setting => {
try {
config[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
config[setting.setting_key] = setting.setting_value;
}
});
return config;
}
module.exports = router;
+102
View File
@@ -7,6 +7,7 @@ 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
@@ -495,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 {
@@ -582,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;
+1 -1
View File
@@ -1,5 +1,5 @@
const express = require('express');
const { db } = require('../database/db');
const { db, withRetry } = require('../database/db');
const { adminAuth } = require('../middleware/auth-enhanced-v2');
const fs = require('fs').promises;
const path = require('path');
+2 -2
View File
@@ -87,10 +87,10 @@ router.post('/', adminAuth, [
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 || ''
});
+17 -6
View File
@@ -1,14 +1,20 @@
const express = require('express');
const { db } = require('../database/db');
const { db, withRetry } = require('../database/db');
const router = express.Router();
// Get public settings (branding and theme)
router.get('/', async (req, res) => {
try {
// Fetch branding, theme, general, and select security settings
const settings = await db('app_settings')
.whereIn('setting_type', ['branding', 'theme', 'general', 'security'])
.select('setting_key', 'setting_value');
// Fetch branding, theme, general, and security settings
// Note: We include analytics in the query but it might not exist yet
const settings = await withRetry(async () => {
return await db('app_settings')
.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
const settingsObject = {};
@@ -41,7 +47,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);
@@ -0,0 +1,251 @@
const { DatabaseBackupService } = require('../databaseBackup');
const { db } = require('../../database/db');
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
// Mock dependencies
jest.mock('../../database/db');
jest.mock('../../utils/logger');
jest.mock('../emailProcessor');
jest.mock('child_process');
describe('DatabaseBackupService', () => {
let service;
let mockExecAsync;
beforeEach(() => {
service = new DatabaseBackupService();
mockExecAsync = jest.fn();
// Reset mocks
jest.clearAllMocks();
// Mock execAsync
const childProcess = require('child_process');
childProcess.exec = jest.fn((cmd, opts, callback) => {
if (callback) {
callback(null, { stdout: 'ok', stderr: '' });
}
});
});
afterEach(async () => {
// Cleanup test files
try {
await fs.rmdir('/tmp/test-backup', { recursive: true });
} catch (e) {
// Ignore
}
});
describe('calculateChecksum', () => {
it('should calculate SHA256 checksum of a file', async () => {
const testFile = '/tmp/test-checksum.txt';
const testContent = 'Hello, World!';
await fs.writeFile(testFile, testContent);
const checksum = await service.calculateChecksum(testFile);
// Expected checksum for "Hello, World!"
const expectedChecksum = crypto
.createHash('sha256')
.update(testContent)
.digest('hex');
expect(checksum).toBe(expectedChecksum);
await fs.unlink(testFile);
});
});
describe('getTableChecksums', () => {
it('should get checksums for all tables', async () => {
// Mock getTables
service.getTables = jest.fn().mockResolvedValue(['events', 'photos']);
// Mock SQLite response
db.raw = jest.fn()
.mockResolvedValueOnce([{ row_count: 10, data_sum: 1000 }])
.mockResolvedValueOnce([{ row_count: 20, data_sum: 2000 }]);
const checksums = await service.getTableChecksums();
expect(checksums).toHaveProperty('events');
expect(checksums).toHaveProperty('photos');
expect(checksums.events.rowCount).toBe(10);
expect(checksums.photos.rowCount).toBe(20);
expect(checksums.events.checksum).toBeDefined();
expect(checksums.photos.checksum).toBeDefined();
});
});
describe('getTables', () => {
it('should get list of tables for SQLite', async () => {
service.dbType = 'sqlite';
db.raw = jest.fn().mockResolvedValue([
{ name: 'events' },
{ name: 'photos' },
{ name: 'admin_users' }
]);
const tables = await service.getTables();
expect(tables).toEqual(['events', 'photos', 'admin_users']);
expect(db.raw).toHaveBeenCalledWith(expect.stringContaining('sqlite_master'));
});
it('should get list of tables for PostgreSQL', async () => {
service.dbType = 'postgresql';
db.raw = jest.fn().mockResolvedValue({
rows: [
{ table_name: 'events' },
{ table_name: 'photos' },
{ table_name: 'admin_users' }
]
});
const tables = await service.getTables();
expect(tables).toEqual(['events', 'photos', 'admin_users']);
expect(db.raw).toHaveBeenCalledWith(expect.stringContaining('information_schema.tables'));
});
});
describe('getDatabaseSize', () => {
it('should get database size for SQLite', async () => {
service.dbType = 'sqlite';
const mockSize = 1024 * 1024 * 10; // 10MB
// Mock fs.stat
const originalStat = fs.stat;
fs.stat = jest.fn().mockResolvedValue({ size: mockSize });
const size = await service.getDatabaseSize();
expect(size).toBe(mockSize);
fs.stat = originalStat;
});
it('should get database size for PostgreSQL', async () => {
service.dbType = 'postgresql';
const mockSize = 1024 * 1024 * 100; // 100MB
db.raw = jest.fn().mockResolvedValue({
rows: [{ size: mockSize.toString() }]
});
const size = await service.getDatabaseSize();
expect(size).toBe(mockSize);
expect(db.raw).toHaveBeenCalledWith(expect.stringContaining('pg_database_size'));
});
});
describe('compressFile', () => {
it('should compress file and return stats', async () => {
const testFile = '/tmp/test-compress.txt';
const compressedFile = '/tmp/test-compress.txt.gz';
// Create test file with repetitive content (compresses well)
const testContent = 'Hello, World! '.repeat(1000);
await fs.writeFile(testFile, testContent);
const stats = await service.compressFile(testFile, compressedFile);
expect(stats.originalSize).toBeGreaterThan(0);
expect(stats.compressedSize).toBeGreaterThan(0);
expect(stats.compressedSize).toBeLessThan(stats.originalSize);
expect(parseFloat(stats.compressionRatio)).toBeGreaterThan(0);
// Cleanup
await fs.unlink(testFile);
await fs.unlink(compressedFile);
});
});
describe('backup configuration', () => {
it('should get backup configuration from database', async () => {
const mockConfig = [
{ setting_key: 'database_backup_enabled', setting_value: 'true' },
{ setting_key: 'database_backup_compress', setting_value: 'true' },
{ setting_key: 'database_backup_retention_days', setting_value: '30' }
];
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue(mockConfig)
});
const config = await service.getBackupConfig();
expect(config.database_backup_enabled).toBe(true);
expect(config.database_backup_compress).toBe(true);
expect(config.database_backup_retention_days).toBe(30);
});
});
describe('cleanupOldBackups', () => {
it('should delete old backup files and records', async () => {
const oldBackups = [
{ id: 1, file_path: '/backup/old1.sql.gz' },
{ id: 2, file_path: '/backup/old2.sql.gz' }
];
db.mockReturnValue({
where: jest.fn().mockReturnThis(),
select: jest.fn().mockResolvedValue(oldBackups),
delete: jest.fn().mockResolvedValue(1)
});
// Mock fs.unlink
fs.unlink = jest.fn().mockResolvedValue(undefined);
await service.cleanupOldBackups(30);
expect(fs.unlink).toHaveBeenCalledTimes(2);
expect(fs.unlink).toHaveBeenCalledWith('/backup/old1.sql.gz');
expect(fs.unlink).toHaveBeenCalledWith('/backup/old2.sql.gz');
});
});
describe('progress tracking', () => {
it('should update and retrieve progress', () => {
expect(service.getProgress()).toBeNull();
service.updateProgress('Testing...', { step: 1 });
const progress = service.getProgress();
expect(progress.message).toBe('Testing...');
expect(progress.details.step).toBe(1);
expect(progress.timestamp).toBeDefined();
});
});
describe('backup history', () => {
it('should retrieve backup history', async () => {
const mockHistory = [
{
id: 1,
started_at: new Date(),
completed_at: new Date(),
status: 'completed',
file_size_bytes: 1024000
}
];
db.mockReturnValue({
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockResolvedValue(mockHistory)
});
const history = await service.getBackupHistory(10);
expect(history).toEqual(mockHistory);
expect(history.length).toBe(1);
});
});
});
+501
View File
@@ -0,0 +1,501 @@
const fs = require('fs').promises;
const path = require('path');
const crypto = require('crypto');
const yaml = require('js-yaml');
const os = require('os');
const { db } = require('../database/db');
const logger = require('../utils/logger');
/**
* Backup Manifest Generator
*
* Generates comprehensive manifests for backups including:
* - Version information (app, node, OS)
* - File listings with metadata and checksums
* - Database information
* - System state at backup time
* - Support for both JSON and YAML formats
* - Incremental backup support with parent references
*/
class BackupManifestGenerator {
constructor() {
this.appVersion = require('../../package.json').version;
this.nodeVersion = process.version;
this.platform = process.platform;
this.osRelease = os.release();
this.hostname = os.hostname();
}
/**
* Generate a comprehensive backup manifest
* @param {Object} options - Manifest generation options
* @param {string} options.backupType - 'full' or 'incremental'
* @param {string} options.backupPath - Path to the backup directory
* @param {Array} options.files - Array of backed up files with metadata
* @param {Object} options.databaseInfo - Database backup information
* @param {string} options.parentBackupId - For incremental backups, reference to parent
* @param {string} options.format - 'json' or 'yaml' (default: 'json')
* @param {Object} options.customMetadata - Additional metadata to include
* @returns {Object} Generated manifest object
*/
async generateManifest(options) {
const {
backupType = 'full',
backupPath,
files = [],
databaseInfo = {},
parentBackupId = null,
format = 'json',
customMetadata = {}
} = options;
const manifest = {
// Manifest metadata
manifest: {
version: '2.0',
created: new Date().toISOString(),
generator: 'PicPeak Backup Manifest Generator',
format: format
},
// Backup information
backup: {
id: this.generateBackupId(),
type: backupType,
timestamp: new Date().toISOString(),
path: backupPath,
parent_backup_id: parentBackupId,
retention_days: customMetadata.retentionDays || 30
},
// System information
system: {
hostname: this.hostname,
platform: this.platform,
os_release: this.osRelease,
architecture: os.arch(),
cpu_count: os.cpus().length,
total_memory: os.totalmem(),
free_memory: os.freemem(),
uptime: os.uptime()
},
// Application information
application: {
name: 'PicPeak',
version: this.appVersion,
node_version: this.nodeVersion,
environment: process.env.NODE_ENV || 'production',
storage_path: process.env.STORAGE_PATH || path.join(__dirname, '../../../storage')
},
// Files information
files: {
count: files.length,
total_size: files.reduce((sum, file) => sum + (file.size || 0), 0),
checksums: await this.generateFileChecksums(files),
manifest: files.map(file => ({
path: file.relativePath || file.path,
size: file.size,
modified: file.modified,
checksum: file.checksum,
type: this.getFileType(file.path),
permissions: file.permissions
}))
},
// Database information
database: {
type: databaseInfo.type || this.getDatabaseType(),
backup_file: databaseInfo.backupFile,
size: databaseInfo.size,
checksum: databaseInfo.checksum,
tables: databaseInfo.tables || {},
row_counts: databaseInfo.rowCounts || {},
schema_version: await this.getSchemaVersion()
},
// Verification information
verification: {
total_checksum: null, // Will be calculated after manifest is complete
file_count_check: files.length,
size_check: files.reduce((sum, file) => sum + (file.size || 0), 0),
integrity_timestamp: new Date().toISOString()
},
// Custom metadata
metadata: {
...customMetadata,
backup_settings: await this.getBackupSettings(),
active_events_count: await this.getActiveEventsCount(),
archived_events_count: await this.getArchivedEventsCount(),
total_photos_count: await this.getTotalPhotosCount()
}
};
// Calculate total checksum of the manifest
manifest.verification.total_checksum = this.calculateManifestChecksum(manifest);
return manifest;
}
/**
* Save manifest to file
* @param {Object} manifest - Manifest object to save
* @param {string} filePath - Path to save the manifest
* @param {string} format - 'json' or 'yaml'
*/
async saveManifest(manifest, filePath, format = 'json') {
try {
let content;
if (format === 'yaml') {
content = yaml.dump(manifest, {
indent: 2,
lineWidth: -1,
noRefs: true,
sortKeys: true
});
} else {
content = JSON.stringify(manifest, null, 2);
}
await fs.writeFile(filePath, content, 'utf8');
logger.info(`Manifest saved to ${filePath} (format: ${format})`);
return filePath;
} catch (error) {
logger.error('Failed to save manifest:', error);
throw error;
}
}
/**
* Load and validate an existing manifest
* @param {string} filePath - Path to the manifest file
* @returns {Object} Loaded and validated manifest
*/
async loadManifest(filePath) {
try {
const content = await fs.readFile(filePath, 'utf8');
let manifest;
// Detect format and parse
if (filePath.endsWith('.yaml') || filePath.endsWith('.yml')) {
manifest = yaml.load(content);
} else {
manifest = JSON.parse(content);
}
// Validate manifest structure
this.validateManifest(manifest);
return manifest;
} catch (error) {
logger.error('Failed to load manifest:', error);
throw error;
}
}
/**
* Validate manifest structure and integrity
* @param {Object} manifest - Manifest to validate
* @throws {Error} If validation fails
*/
validateManifest(manifest) {
// Check required sections
const requiredSections = ['manifest', 'backup', 'system', 'application', 'files', 'database', 'verification'];
for (const section of requiredSections) {
if (!manifest[section]) {
throw new Error(`Missing required section: ${section}`);
}
}
// Validate manifest version
if (!manifest.manifest.version) {
throw new Error('Missing manifest version');
}
// Validate file checksums
if (manifest.files.count !== manifest.files.manifest.length) {
throw new Error('File count mismatch');
}
// Validate total checksum
const calculatedChecksum = this.calculateManifestChecksum(manifest);
if (manifest.verification.total_checksum !== calculatedChecksum) {
throw new Error('Manifest checksum verification failed');
}
logger.info('Manifest validation passed');
return true;
}
/**
* Compare two manifests for incremental backup
* @param {Object} currentManifest - Current backup manifest
* @param {Object} parentManifest - Parent backup manifest
* @returns {Object} Comparison results
*/
compareManifests(currentManifest, parentManifest) {
const comparison = {
added_files: [],
modified_files: [],
deleted_files: [],
unchanged_files: [],
size_difference: 0,
database_changes: {}
};
// Create file maps for easy comparison
const currentFiles = new Map(
currentManifest.files.manifest.map(f => [f.path, f])
);
const parentFiles = new Map(
parentManifest.files.manifest.map(f => [f.path, f])
);
// Find added and modified files
for (const [path, file] of currentFiles) {
const parentFile = parentFiles.get(path);
if (!parentFile) {
comparison.added_files.push(file);
comparison.size_difference += file.size;
} else if (file.checksum !== parentFile.checksum) {
comparison.modified_files.push(file);
comparison.size_difference += file.size - parentFile.size;
} else {
comparison.unchanged_files.push(file);
}
}
// Find deleted files
for (const [path, file] of parentFiles) {
if (!currentFiles.has(path)) {
comparison.deleted_files.push(file);
comparison.size_difference -= file.size;
}
}
// Compare database info
comparison.database_changes = {
size_difference: currentManifest.database.size - parentManifest.database.size,
checksum_changed: currentManifest.database.checksum !== parentManifest.database.checksum,
schema_version_changed: currentManifest.database.schema_version !== parentManifest.database.schema_version
};
return comparison;
}
/**
* Generate incremental manifest based on parent
* @param {Object} options - Manifest generation options
* @param {Object} parentManifest - Parent backup manifest
* @returns {Object} Incremental manifest
*/
async generateIncrementalManifest(options, parentManifest) {
const fullManifest = await this.generateManifest({
...options,
backupType: 'incremental'
});
const comparison = this.compareManifests(fullManifest, parentManifest);
// Add incremental-specific information
fullManifest.incremental = {
parent_backup_id: parentManifest.backup.id,
parent_timestamp: parentManifest.backup.timestamp,
changes: {
added_files_count: comparison.added_files.length,
modified_files_count: comparison.modified_files.length,
deleted_files_count: comparison.deleted_files.length,
unchanged_files_count: comparison.unchanged_files.length,
size_difference: comparison.size_difference
},
added_files: comparison.added_files.map(f => f.path),
modified_files: comparison.modified_files.map(f => f.path),
deleted_files: comparison.deleted_files.map(f => f.path)
};
return fullManifest;
}
// Helper methods
generateBackupId() {
const timestamp = new Date().toISOString().replace(/[:-]/g, '').replace('T', '-').split('.')[0];
const random = crypto.randomBytes(4).toString('hex');
return `backup-${timestamp}-${random}`;
}
async generateFileChecksums(files) {
const checksums = {};
for (const file of files) {
if (file.checksum) {
checksums[file.relativePath || file.path] = file.checksum;
}
}
return checksums;
}
getFileType(filePath) {
const ext = path.extname(filePath).toLowerCase();
const typeMap = {
'.jpg': 'image',
'.jpeg': 'image',
'.png': 'image',
'.gif': 'image',
'.webp': 'image',
'.zip': 'archive',
'.sql': 'database',
'.db': 'database',
'.json': 'config',
'.yaml': 'config',
'.yml': 'config'
};
return typeMap[ext] || 'other';
}
getDatabaseType() {
return process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite';
}
async getSchemaVersion() {
try {
const result = await db('migrations')
.orderBy('run_at', 'desc')
.first();
return result ? result.migration_name : 'unknown';
} catch (error) {
return 'unknown';
}
}
async getBackupSettings() {
try {
const settings = await db('app_settings')
.where('setting_type', 'backup')
.select('setting_key', 'setting_value');
const config = {};
settings.forEach(setting => {
try {
config[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
config[setting.setting_key] = setting.setting_value;
}
});
return config;
} catch (error) {
return {};
}
}
async getActiveEventsCount() {
try {
const result = await db('events')
.where('status', 'active')
.count('* as count')
.first();
return result ? parseInt(result.count) : 0;
} catch (error) {
return 0;
}
}
async getArchivedEventsCount() {
try {
const result = await db('events')
.where('status', 'archived')
.count('* as count')
.first();
return result ? parseInt(result.count) : 0;
} catch (error) {
return 0;
}
}
async getTotalPhotosCount() {
try {
const result = await db('photos')
.count('* as count')
.first();
return result ? parseInt(result.count) : 0;
} catch (error) {
return 0;
}
}
calculateManifestChecksum(manifest) {
// Create a copy without the checksum field
const manifestCopy = JSON.parse(JSON.stringify(manifest));
if (manifestCopy.verification) {
delete manifestCopy.verification.total_checksum;
}
// Calculate SHA256 of the sorted JSON
const content = JSON.stringify(manifestCopy, Object.keys(manifestCopy).sort());
return crypto.createHash('sha256').update(content).digest('hex');
}
/**
* Generate a summary report from a manifest
* @param {Object} manifest - Manifest to summarize
* @returns {string} Human-readable summary
*/
generateSummaryReport(manifest) {
const report = [];
report.push('=== BACKUP MANIFEST SUMMARY ===');
report.push(`Backup ID: ${manifest.backup.id}`);
report.push(`Type: ${manifest.backup.type}`);
report.push(`Created: ${manifest.backup.timestamp}`);
if (manifest.backup.parent_backup_id) {
report.push(`Parent Backup: ${manifest.backup.parent_backup_id}`);
}
report.push('\n--- System Information ---');
report.push(`Host: ${manifest.system.hostname}`);
report.push(`Platform: ${manifest.system.platform} ${manifest.system.os_release}`);
report.push(`Architecture: ${manifest.system.architecture}`);
report.push('\n--- Application Information ---');
report.push(`App Version: ${manifest.application.version}`);
report.push(`Node Version: ${manifest.application.node_version}`);
report.push(`Environment: ${manifest.application.environment}`);
report.push('\n--- Files Summary ---');
report.push(`Total Files: ${manifest.files.count}`);
report.push(`Total Size: ${(manifest.files.total_size / 1024 / 1024).toFixed(2)} MB`);
if (manifest.incremental) {
report.push('\n--- Incremental Changes ---');
report.push(`Added Files: ${manifest.incremental.changes.added_files_count}`);
report.push(`Modified Files: ${manifest.incremental.changes.modified_files_count}`);
report.push(`Deleted Files: ${manifest.incremental.changes.deleted_files_count}`);
report.push(`Size Difference: ${(manifest.incremental.changes.size_difference / 1024 / 1024).toFixed(2)} MB`);
}
report.push('\n--- Database Information ---');
report.push(`Type: ${manifest.database.type}`);
report.push(`Size: ${manifest.database.size ? (manifest.database.size / 1024 / 1024).toFixed(2) + ' MB' : 'N/A'}`);
report.push(`Schema Version: ${manifest.database.schema_version}`);
report.push('\n--- Content Statistics ---');
report.push(`Active Events: ${manifest.metadata.active_events_count}`);
report.push(`Archived Events: ${manifest.metadata.archived_events_count}`);
report.push(`Total Photos: ${manifest.metadata.total_photos_count}`);
report.push('\n--- Verification ---');
report.push(`Manifest Checksum: ${manifest.verification.total_checksum}`);
report.push(`Integrity Timestamp: ${manifest.verification.integrity_timestamp}`);
return report.join('\n');
}
}
// Export singleton instance
module.exports = new BackupManifestGenerator();
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,720 @@
const cron = require('node-cron');
const path = require('path');
const fs = require('fs').promises;
const crypto = require('crypto');
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const { db } = require('../database/db');
const { queueEmail } = require('./emailProcessor');
const logger = require('../utils/logger');
const { formatBoolean } = require('../utils/dbCompat');
const backupManifest = require('./backupManifest');
// Backup job reference
let backupJob = null;
let backupConfig = null;
let isRunning = false;
// Storage paths
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
/**
* Calculate file checksum using SHA256
*/
async function calculateChecksum(filePath) {
const hash = crypto.createHash('sha256');
const stream = require('fs').createReadStream(filePath);
return new Promise((resolve, reject) => {
stream.on('data', data => hash.update(data));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* Get database backup information
*/
async function getDatabaseBackupInfo() {
try {
// Check for recent database backup
const recentDbBackup = await db('database_backup_runs')
.where('status', 'completed')
.orderBy('completed_at', 'desc')
.first();
if (recentDbBackup && recentDbBackup.file_path) {
return {
type: recentDbBackup.backup_type,
backupFile: recentDbBackup.file_path,
size: recentDbBackup.file_size_bytes,
checksum: recentDbBackup.checksum,
tables: recentDbBackup.statistics ? JSON.parse(recentDbBackup.statistics).tables : {},
rowCounts: recentDbBackup.table_checksums ? JSON.parse(recentDbBackup.table_checksums) : {}
};
}
return {
type: process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite',
backupFile: null,
size: 0,
checksum: null,
tables: {},
rowCounts: {}
};
} catch (error) {
logger.error('Failed to get database backup info:', error);
return {
type: 'unknown',
backupFile: null,
size: 0,
checksum: null,
tables: {},
rowCounts: {}
};
}
}
/**
* Get backup configuration from database
*/
async function getBackupConfig() {
try {
const settings = await db('app_settings')
.where('setting_type', 'backup')
.select('setting_key', 'setting_value');
const config = {};
settings.forEach(setting => {
try {
config[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
config[setting.setting_key] = setting.setting_value;
}
});
return config;
} catch (error) {
logger.error('Failed to get backup configuration:', error);
return null;
}
}
/**
* Get list of files to backup
*/
async function getFilesToBackup(includeArchived = true) {
const files = [];
const storagePath = getStoragePath();
try {
// Active events
const activePath = path.join(storagePath, 'events/active');
await scanDirectory(activePath, files, storagePath);
// Archived events (if enabled)
if (includeArchived) {
const archivePath = path.join(storagePath, 'events/archived');
await scanDirectory(archivePath, files, storagePath);
}
// Thumbnails
const thumbsPath = path.join(storagePath, 'thumbnails');
await scanDirectory(thumbsPath, files, storagePath);
// Uploads (logos, favicons, etc.)
const uploadsPath = path.join(storagePath, 'uploads');
await scanDirectory(uploadsPath, files, storagePath);
return files;
} catch (error) {
logger.error('Failed to get files to backup:', error);
throw error;
}
}
/**
* Recursively scan directory for files
*/
async function scanDirectory(dirPath, fileList, basePath, excludePatterns = []) {
try {
const entries = await fs.readdir(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
const relativePath = path.relative(basePath, fullPath);
// Check exclude patterns
if (excludePatterns.some(pattern => {
if (pattern.includes('*')) {
return new RegExp(pattern.replace(/\*/g, '.*')).test(entry.name);
}
return entry.name === pattern;
})) {
continue;
}
if (entry.isDirectory()) {
await scanDirectory(fullPath, fileList, basePath, excludePatterns);
} else if (entry.isFile()) {
const stats = await fs.stat(fullPath);
fileList.push({
path: fullPath,
relativePath: relativePath,
size: stats.size,
modified: stats.mtime
});
}
}
} catch (error) {
if (error.code !== 'ENOENT') {
logger.error(`Failed to scan directory ${dirPath}:`, error);
}
}
}
/**
* Check if file has changed since last backup
*/
async function hasFileChanged(filePath, checksum) {
try {
const fileState = await db('backup_file_states')
.where('file_path', filePath)
.first();
return !fileState || fileState.checksum !== checksum;
} catch (error) {
logger.error('Failed to check file state:', error);
return true; // Assume changed if we can't check
}
}
/**
* Update file state in database
*/
async function updateFileState(filePath, checksum, size, modified) {
try {
const existing = await db('backup_file_states')
.where('file_path', filePath)
.first();
const data = {
file_path: filePath,
checksum: checksum,
size_bytes: size,
last_modified: modified,
last_backed_up: new Date()
};
if (existing) {
await db('backup_file_states')
.where('id', existing.id)
.update(data);
} else {
await db('backup_file_states').insert(data);
}
} catch (error) {
logger.error('Failed to update file state:', error);
}
}
/**
* Perform local directory backup
*/
async function performLocalBackup(config, files) {
const destPath = config.backup_destination_path;
const storagePath = getStoragePath();
let backedUpCount = 0;
let backedUpSize = 0;
const backedUpFiles = [];
// Ensure destination exists
await fs.mkdir(destPath, { recursive: true });
for (const file of files) {
try {
// Skip large files if configured
const maxSizeMB = config.backup_max_file_size_mb || 5000;
if (file.size > maxSizeMB * 1024 * 1024) {
logger.warn(`Skipping large file: ${file.relativePath} (${(file.size / 1024 / 1024).toFixed(2)} MB)`);
continue;
}
// Calculate checksum
const checksum = await calculateChecksum(file.path);
file.checksum = checksum; // Add checksum to file object
// Check if file has changed
const changed = await hasFileChanged(file.relativePath, checksum);
if (!changed) {
continue;
}
// Copy file
const destFilePath = path.join(destPath, file.relativePath);
const destDir = path.dirname(destFilePath);
await fs.mkdir(destDir, { recursive: true });
await fs.copyFile(file.path, destFilePath);
// Update state
await updateFileState(file.relativePath, checksum, file.size, file.modified);
backedUpCount++;
backedUpSize += file.size;
backedUpFiles.push(file.relativePath);
} catch (error) {
logger.error(`Failed to backup file ${file.relativePath}:`, error);
}
}
return { backedUpCount, backedUpSize, backedUpFiles };
}
/**
* Perform rsync backup
*/
async function performRsyncBackup(config, files) {
const storagePath = getStoragePath();
const host = config.backup_rsync_host;
const user = config.backup_rsync_user;
const remotePath = config.backup_rsync_path;
const sshKey = config.backup_rsync_ssh_key;
if (!host || !remotePath) {
throw new Error('Rsync configuration incomplete');
}
// Build rsync command
const rsyncOptions = [
'-avz', // archive, verbose, compress
'--delete', // remove deleted files
'--stats' // show statistics
];
if (sshKey) {
rsyncOptions.push(`-e "ssh -i ${sshKey} -o StrictHostKeyChecking=no"`);
}
// Add exclude patterns
const excludePatterns = config.backup_exclude_patterns || [];
excludePatterns.forEach(pattern => {
rsyncOptions.push(`--exclude="${pattern}"`);
});
const source = `${storagePath}/`;
const destination = user ? `${user}@${host}:${remotePath}` : `${host}:${remotePath}`;
const rsyncCommand = `rsync ${rsyncOptions.join(' ')} "${source}" "${destination}"`;
try {
const { stdout, stderr } = await execAsync(rsyncCommand);
// Parse rsync stats
const stats = parseRsyncStats(stdout);
// Update file states for successfully synced files
for (const file of files) {
try {
const checksum = await calculateChecksum(file.path);
await updateFileState(file.relativePath, checksum, file.size, file.modified);
} catch (error) {
logger.error(`Failed to update state for ${file.relativePath}:`, error);
}
}
return {
backedUpCount: stats.filesTransferred || files.length,
backedUpSize: stats.totalSize || files.reduce((sum, f) => sum + f.size, 0),
backedUpFiles: files.map(f => f.relativePath)
};
} catch (error) {
logger.error('Rsync backup failed:', error);
throw new Error(`Rsync backup failed: ${error.message}`);
}
}
/**
* Parse rsync statistics from output
*/
function parseRsyncStats(output) {
const stats = {};
// Extract files transferred
const filesMatch = output.match(/Number of files transferred: (\d+)/);
if (filesMatch) {
stats.filesTransferred = parseInt(filesMatch[1]);
}
// Extract total size
const sizeMatch = output.match(/Total file size: ([\d,]+) bytes/);
if (sizeMatch) {
stats.totalSize = parseInt(sizeMatch[1].replace(/,/g, ''));
}
return stats;
}
/**
* Perform S3-compatible backup
*/
async function performS3Backup(config, files) {
// This would require AWS SDK or similar
// For now, return a placeholder
throw new Error('S3 backup not implemented yet');
}
/**
* Run backup process
*/
async function runBackup() {
if (isRunning) {
logger.warn('Backup already running, skipping');
return;
}
isRunning = true;
const startTime = new Date();
let backupRun = null;
try {
// Get current configuration
const config = await getBackupConfig();
if (!config.backup_enabled) {
logger.info('Backup is disabled, skipping');
return;
}
// Create backup run record
const [runId] = await db('backup_runs').insert({
started_at: startTime,
status: 'running',
backup_type: 'scheduled'
});
backupRun = { id: runId };
// Get files to backup
const files = await getFilesToBackup(config.backup_include_archived);
logger.info(`Found ${files.length} files to check for backup`);
// Perform backup based on destination type
let result;
switch (config.backup_destination_type) {
case 'local':
result = await performLocalBackup(config, files);
break;
case 'rsync':
result = await performRsyncBackup(config, files);
break;
case 's3':
result = await performS3Backup(config, files);
break;
default:
throw new Error(`Unknown backup destination type: ${config.backup_destination_type}`);
}
// Calculate duration
const endTime = new Date();
const durationSeconds = Math.round((endTime - startTime) / 1000);
// Generate backup manifest
let manifestPath = null;
try {
logger.info('Generating backup manifest...');
// Get database backup info if available
const databaseInfo = await getDatabaseBackupInfo();
// Determine if this is an incremental backup
const lastSuccessfulBackup = await db('backup_runs')
.where('status', 'completed')
.whereNot('id', runId)
.orderBy('completed_at', 'desc')
.first();
let manifest;
const manifestOptions = {
backupType: lastSuccessfulBackup ? 'incremental' : 'full',
backupPath: config.backup_destination_path || config.backup_destination_type,
files: files.filter(f => result.backedUpFiles && result.backedUpFiles.includes(f.relativePath)),
databaseInfo: databaseInfo,
parentBackupId: lastSuccessfulBackup ? lastSuccessfulBackup.manifest_id : null,
format: config.backup_manifest_format || 'json',
customMetadata: {
backup_run_id: runId,
destination_type: config.backup_destination_type,
operator: 'system',
reason: 'scheduled',
retentionDays: config.backup_retention_days || 30
}
};
if (lastSuccessfulBackup && lastSuccessfulBackup.manifest_path) {
try {
const parentManifest = await backupManifest.loadManifest(lastSuccessfulBackup.manifest_path);
manifest = await backupManifest.generateIncrementalManifest(manifestOptions, parentManifest);
} catch (error) {
logger.warn('Failed to load parent manifest, generating full manifest:', error);
manifest = await backupManifest.generateManifest(manifestOptions);
}
} else {
manifest = await backupManifest.generateManifest(manifestOptions);
}
// Save manifest
const manifestDir = config.backup_manifest_path || path.join(config.backup_destination_path || '/backup', 'manifests');
await fs.mkdir(manifestDir, { recursive: true });
const manifestFileName = `backup-manifest-${manifest.backup.id}.${config.backup_manifest_format || 'json'}`;
manifestPath = path.join(manifestDir, manifestFileName);
await backupManifest.saveManifest(manifest, manifestPath, config.backup_manifest_format || 'json');
logger.info(`Backup manifest saved to ${manifestPath}`);
} catch (error) {
logger.error('Failed to generate backup manifest:', error);
// Don't fail the entire backup for manifest generation failure
}
// Update backup run record
await db('backup_runs')
.where('id', runId)
.update({
completed_at: endTime,
status: 'completed',
files_backed_up: result.backedUpCount,
total_size_bytes: result.backedUpSize,
duration_seconds: durationSeconds,
manifest_path: manifestPath,
manifest_id: manifestPath ? path.basename(manifestPath, path.extname(manifestPath)) : null,
statistics: JSON.stringify({
totalFilesChecked: files.length,
filesBackedUp: result.backedUpCount,
totalSize: result.backedUpSize,
averageFileSize: result.backedUpCount > 0 ? Math.round(result.backedUpSize / result.backedUpCount) : 0,
manifestGenerated: !!manifestPath
})
});
logger.info(`Backup completed: ${result.backedUpCount} files, ${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB in ${durationSeconds}s`);
// Send success email if configured
if (config.backup_email_on_success) {
// Get admin emails
const admins = await db('admin_users').where('is_active', formatBoolean(true));
for (const admin of admins) {
await queueEmail(null, admin.email, 'backup_completed', {
start_time: startTime.toISOString(),
duration: `${durationSeconds} seconds`,
files_count: result.backedUpCount.toString(),
total_size: `${(result.backedUpSize / 1024 / 1024).toFixed(2)} MB`,
backup_type: config.backup_destination_type
});
}
}
} catch (error) {
logger.error('Backup failed:', error);
// Update backup run record
if (backupRun) {
await db('backup_runs')
.where('id', backupRun.id)
.update({
completed_at: new Date(),
status: 'failed',
error_message: error.message
});
}
// Send failure email
const config = await getBackupConfig();
if (config && config.backup_email_on_failure) {
const admins = await db('admin_users').where('is_active', formatBoolean(true));
for (const admin of admins) {
await queueEmail(null, admin.email, 'backup_failed', {
start_time: startTime.toISOString(),
backup_type: config.backup_destination_type || 'unknown',
error_message: error.message
});
}
}
} finally {
isRunning = false;
}
}
/**
* Start backup service
*/
async function startBackupService() {
try {
// Get configuration
backupConfig = await getBackupConfig();
if (!backupConfig || !backupConfig.backup_enabled) {
logger.info('Backup service is disabled');
return;
}
// Cancel existing job if any
if (backupJob) {
backupJob.stop();
}
// Schedule backup job
const schedule = backupConfig.backup_schedule || '0 2 * * *'; // Default: 2 AM daily
backupJob = cron.schedule(schedule, async () => {
logger.info('Starting scheduled backup');
await runBackup();
});
logger.info(`Backup service started with schedule: ${schedule}`);
} catch (error) {
logger.error('Failed to start backup service:', error);
}
}
/**
* Stop backup service
*/
function stopBackupService() {
if (backupJob) {
backupJob.stop();
backupJob = null;
logger.info('Backup service stopped');
}
}
/**
* Trigger manual backup
*/
async function triggerManualBackup() {
logger.info('Starting manual backup');
await runBackup();
}
/**
* Get backup status and history
*/
async function getBackupStatus(limit = 10) {
try {
const runs = await db('backup_runs')
.orderBy('started_at', 'desc')
.limit(limit);
const lastRun = runs[0];
const isHealthy = lastRun && lastRun.status === 'completed';
// Validate manifest if exists
let manifestValid = false;
if (lastRun && lastRun.manifest_path) {
try {
const manifest = await backupManifest.loadManifest(lastRun.manifest_path);
backupManifest.validateManifest(manifest);
manifestValid = true;
} catch (error) {
logger.warn('Manifest validation failed:', error);
}
}
return {
isRunning,
isHealthy,
lastRun: lastRun ? {
...lastRun,
manifestValid
} : null,
recentRuns: runs,
nextScheduledRun: backupJob ? getNextScheduledRun() : null
};
} catch (error) {
logger.error('Failed to get backup status:', error);
return {
isRunning,
isHealthy: false,
error: error.message
};
}
}
/**
* Get next scheduled run time
*/
function getNextScheduledRun() {
// This is a simplified version - would need proper cron parsing
const now = new Date();
const tomorrow = new Date(now);
tomorrow.setDate(tomorrow.getDate() + 1);
tomorrow.setHours(2, 0, 0, 0); // Assuming default 2 AM schedule
return tomorrow.toISOString();
}
/**
* Clean up old backup runs
*/
async function cleanupOldBackupRuns(retentionDays = 30) {
try {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const deleted = await db('backup_runs')
.where('started_at', '<', cutoffDate)
.delete();
if (deleted > 0) {
logger.info(`Cleaned up ${deleted} old backup runs`);
}
} catch (error) {
logger.error('Failed to cleanup old backup runs:', error);
}
}
/**
* Get backup manifest for a specific backup run
*/
async function getBackupManifest(backupRunId) {
try {
const run = await db('backup_runs')
.where('id', backupRunId)
.first();
if (!run || !run.manifest_path) {
throw new Error('Backup manifest not found');
}
const manifest = await backupManifest.loadManifest(run.manifest_path);
return {
manifest,
summary: backupManifest.generateSummaryReport(manifest)
};
} catch (error) {
logger.error('Failed to get backup manifest:', error);
throw error;
}
}
/**
* Validate a backup manifest file
*/
async function validateBackupManifest(manifestPath) {
try {
const manifest = await backupManifest.loadManifest(manifestPath);
backupManifest.validateManifest(manifest);
return { valid: true, manifest };
} catch (error) {
return { valid: false, error: error.message };
}
}
module.exports = {
startBackupService,
stopBackupService,
triggerManualBackup,
getBackupStatus,
runBackup,
cleanupOldBackupRuns,
getBackupManifest,
validateBackupManifest
};
@@ -0,0 +1,147 @@
/**
* Database Backup Service Usage Examples
*
* This service provides comprehensive database backup functionality
* with support for both SQLite and PostgreSQL databases.
*/
const { databaseBackupService } = require('./databaseBackup');
// Example 1: Manual backup with default settings
async function manualBackup() {
try {
const result = await databaseBackupService.backup();
console.log('Backup completed:', result);
// Result includes: path, size, duration, checksum, compressionRatio
} catch (error) {
console.error('Backup failed:', error);
}
}
// Example 2: Backup with custom options
async function customBackup() {
try {
const result = await databaseBackupService.backup({
destinationPath: '/custom/backup/path',
compress: true, // Enable gzip compression
validateIntegrity: true, // Validate backup after creation
includeChecksums: true, // Calculate table checksums
noTransaction: false // Use transaction for consistency (PostgreSQL)
});
console.log('Custom backup completed:', result);
} catch (error) {
console.error('Backup failed:', error);
}
}
// Example 3: Check backup progress (useful for long-running backups)
async function backupWithProgress() {
// Start backup asynchronously
const backupPromise = databaseBackupService.backup();
// Poll for progress
const progressInterval = setInterval(() => {
const progress = databaseBackupService.getProgress();
if (progress) {
console.log(`Progress: ${progress.message}`, progress.details);
}
}, 1000);
try {
const result = await backupPromise;
clearInterval(progressInterval);
console.log('Backup completed:', result);
} catch (error) {
clearInterval(progressInterval);
console.error('Backup failed:', error);
}
}
// Example 4: Get backup history
async function getBackupHistory() {
const history = await databaseBackupService.getBackupHistory(10);
history.forEach(backup => {
console.log(`Backup ${backup.id}:`);
console.log(` Started: ${backup.started_at}`);
console.log(` Status: ${backup.status}`);
console.log(` Size: ${(backup.file_size_bytes / 1024 / 1024).toFixed(2)} MB`);
console.log(` Duration: ${backup.duration_seconds}s`);
});
}
// Example 5: Clean up old backups
async function cleanupBackups() {
// Delete backups older than 30 days
await databaseBackupService.cleanupOldBackups(30);
console.log('Old backups cleaned up');
}
// Example 6: Get table checksums (useful for monitoring changes)
async function getTableChecksums() {
const checksums = await databaseBackupService.getTableChecksums();
console.log('Table Checksums:');
Object.entries(checksums).forEach(([table, info]) => {
console.log(` ${table}: ${info.rowCount} rows, checksum: ${info.checksum}`);
});
}
// Example 7: Using the scheduled backup service
const { startScheduledBackups, stopScheduledBackups } = require('./databaseBackup');
async function setupScheduledBackups() {
// Start scheduled backups (reads schedule from database config)
await startScheduledBackups();
console.log('Scheduled backups started');
// Later, if needed, stop scheduled backups
// stopScheduledBackups();
}
// Example 8: Admin API endpoints available
/*
GET /api/admin/database-backup/status - Get backup status and config
PUT /api/admin/database-backup/config - Update backup configuration
POST /api/admin/database-backup/backup - Trigger manual backup
GET /api/admin/database-backup/progress - Get current backup progress
GET /api/admin/database-backup/history - Get backup history with pagination
DELETE /api/admin/database-backup/cleanup - Delete old backup files
POST /api/admin/database-backup/test - Test backup configuration
GET /api/admin/database-backup/checksums - Get current table checksums
*/
// Example 9: Configuration options stored in database
/*
database_backup_enabled: boolean - Enable/disable scheduled backups
database_backup_schedule: string - Cron schedule (default: '0 3 * * *')
database_backup_destination_path: string - Where to store backups
database_backup_compress: boolean - Enable gzip compression
database_backup_validate_integrity: boolean - Validate after backup
database_backup_include_checksums: boolean - Calculate table checksums
database_backup_retention_days: number - Days to keep old backups
database_backup_email_on_failure: boolean - Send email on failure
database_backup_email_on_success: boolean - Send email on success
*/
// Example 10: Production considerations
/*
1. Ensure destination path has sufficient space
2. For large databases, backups may take significant time
3. PostgreSQL backups use single-transaction mode by default
4. Compression typically reduces size by 70-90%
5. Schedule backups during low-traffic periods
6. Monitor backup history for failures
7. Test restore procedures regularly
8. Consider replication for real-time redundancy
*/
module.exports = {
manualBackup,
customBackup,
backupWithProgress,
getBackupHistory,
cleanupBackups,
getTableChecksums,
setupScheduledBackups
};
+631
View File
@@ -0,0 +1,631 @@
const fs = require('fs').promises;
const path = require('path');
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
const crypto = require('crypto');
const zlib = require('zlib');
const { pipeline } = require('stream/promises');
const { createReadStream, createWriteStream } = require('fs');
const { db } = require('../database/db');
const knexConfig = require('../../knexfile');
const logger = require('../utils/logger');
const { queueEmail } = require('./emailProcessor');
const { formatBoolean } = require('../utils/dbCompat');
// Constants
const CHUNK_SIZE = 1024 * 1024; // 1MB chunks for streaming
const PROGRESS_INTERVAL = 100; // Report progress every 100 rows
/**
* Database Backup Service
* Supports both SQLite and PostgreSQL with proper escaping,
* compression, checksums, and validation
*/
class DatabaseBackupService {
constructor() {
this.isRunning = false;
this.currentProgress = null;
this.dbType = knexConfig.client === 'pg' ? 'postgresql' : 'sqlite';
}
/**
* Calculate checksum for a file
*/
async calculateChecksum(filePath) {
const hash = crypto.createHash('sha256');
const stream = createReadStream(filePath);
return new Promise((resolve, reject) => {
stream.on('data', data => hash.update(data));
stream.on('end', () => resolve(hash.digest('hex')));
stream.on('error', reject);
});
}
/**
* Compress a file using gzip
*/
async compressFile(inputPath, outputPath) {
const gzip = zlib.createGzip({ level: 6 }); // Balanced compression
const source = createReadStream(inputPath);
const destination = createWriteStream(outputPath);
await pipeline(source, gzip, destination);
// Get compression ratio
const inputStats = await fs.stat(inputPath);
const outputStats = await fs.stat(outputPath);
const ratio = (1 - outputStats.size / inputStats.size) * 100;
return {
originalSize: inputStats.size,
compressedSize: outputStats.size,
compressionRatio: ratio.toFixed(2)
};
}
/**
* Get database size
*/
async getDatabaseSize() {
if (this.dbType === 'sqlite') {
const dbPath = knexConfig.connection.filename;
const stats = await fs.stat(dbPath);
return stats.size;
} else {
// PostgreSQL
const result = await db.raw(`
SELECT pg_database_size(current_database()) as size
`);
return parseInt(result.rows[0].size);
}
}
/**
* Get table checksums for change detection
*/
async getTableChecksums() {
const checksums = {};
const tables = await this.getTables();
for (const table of tables) {
if (this.dbType === 'sqlite') {
// SQLite: Use aggregate of all row data
const result = await db.raw(`
SELECT
COUNT(*) as row_count,
COALESCE(SUM(LENGTH(CAST(t.* AS TEXT))), 0) as data_sum
FROM "${table}" t
`);
checksums[table] = {
rowCount: result[0].row_count,
checksum: crypto
.createHash('md5')
.update(`${result[0].row_count}-${result[0].data_sum}`)
.digest('hex')
};
} else {
// PostgreSQL: Use built-in functions
const result = await db.raw(`
SELECT
COUNT(*) as row_count,
MD5(COALESCE(STRING_AGG(MD5(t::text), ''), '')) as checksum
FROM "${table}" t
`);
checksums[table] = {
rowCount: parseInt(result.rows[0].row_count),
checksum: result.rows[0].checksum || 'empty'
};
}
}
return checksums;
}
/**
* Get list of tables
*/
async getTables() {
if (this.dbType === 'sqlite') {
const result = await db.raw(`
SELECT name FROM sqlite_master
WHERE type='table'
AND name NOT LIKE 'sqlite_%'
AND name != 'knex_migrations'
AND name != 'knex_migrations_lock'
ORDER BY name
`);
return result.map(row => row.name);
} else {
// PostgreSQL
const result = await db.raw(`
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_type = 'BASE TABLE'
AND table_name NOT IN ('knex_migrations', 'knex_migrations_lock')
ORDER BY table_name
`);
return result.rows.map(row => row.table_name);
}
}
/**
* Create SQLite backup
*/
async createSQLiteBackup(outputPath, options = {}) {
const dbPath = knexConfig.connection.filename;
const tempPath = `${outputPath}.tmp`;
try {
// Use SQLite's backup API for consistency
await execAsync(`sqlite3 "${dbPath}" ".backup '${tempPath}'"`);
// Verify the backup
const verifyResult = await execAsync(`sqlite3 "${tempPath}" "PRAGMA integrity_check"`);
if (!verifyResult.stdout.includes('ok')) {
throw new Error('Backup integrity check failed');
}
// Move temp file to final location
await fs.rename(tempPath, outputPath);
return { success: true };
} catch (error) {
// Cleanup temp file if exists
try {
await fs.unlink(tempPath);
} catch (e) {
// Ignore
}
throw error;
}
}
/**
* Create PostgreSQL backup with proper escaping
*/
async createPostgreSQLBackup(outputPath, options = {}) {
const { host, port, user, password, database } = knexConfig.connection;
// Build connection string with proper escaping
const connectionParts = [
`host=${host}`,
`port=${port}`,
`dbname=${database}`,
`user=${user}`
];
// Set PGPASSWORD environment variable for security
const env = { ...process.env };
if (password) {
env.PGPASSWORD = password;
}
// Build pg_dump command with options
const pgDumpOptions = [
'--verbose',
'--no-owner',
'--no-privileges',
'--clean',
'--if-exists',
'--format=plain',
'--encoding=UTF8'
];
// Add transaction support for consistency
if (!options.noTransaction) {
pgDumpOptions.push('--single-transaction');
}
// Add compression if not doing it separately
if (options.compress && !options.separateCompression) {
pgDumpOptions.push('--compress=6');
}
const command = `pg_dump "${connectionParts.join(' ')}" ${pgDumpOptions.join(' ')} > "${outputPath}"`;
try {
const { stderr } = await execAsync(command, {
env,
maxBuffer: 1024 * 1024 * 100 // 100MB buffer
});
// pg_dump writes progress to stderr, not an error
if (stderr && !stderr.includes('dump complete')) {
logger.warn('pg_dump warnings:', stderr);
}
// Verify the dump file is not empty
const stats = await fs.stat(outputPath);
if (stats.size === 0) {
throw new Error('Backup file is empty');
}
return { success: true, warnings: stderr };
} catch (error) {
throw new Error(`PostgreSQL backup failed: ${error.message}`);
}
}
/**
* Validate backup integrity
*/
async validateBackup(backupPath, originalChecksums) {
const tempDbPath = `${backupPath}.validate`;
try {
if (this.dbType === 'sqlite') {
// For SQLite, we can directly check integrity
const result = await execAsync(`sqlite3 "${backupPath}" "PRAGMA integrity_check"`);
if (!result.stdout.includes('ok')) {
throw new Error('Backup integrity check failed');
}
} else {
// For PostgreSQL, we'd need to restore to a temp database
// This is more complex and might not be feasible in production
logger.info('PostgreSQL backup validation would require restore test');
}
return { valid: true };
} finally {
// Cleanup
try {
await fs.unlink(tempDbPath);
} catch (e) {
// Ignore
}
}
}
/**
* Main backup method
*/
async backup(options = {}) {
if (this.isRunning) {
throw new Error('Backup already in progress');
}
this.isRunning = true;
const startTime = new Date();
let backupRun = null;
try {
// Get configuration
const config = await this.getBackupConfig();
const {
destinationPath = '/backup/database',
compress = true,
validateIntegrity = true,
includeChecksums = true
} = { ...config, ...options };
// Create backup directory
await fs.mkdir(destinationPath, { recursive: true });
// Generate backup filename
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const baseName = `picpeak-db-${this.dbType}-${timestamp}`;
const sqlFile = path.join(destinationPath, `${baseName}.sql`);
const finalFile = compress ? path.join(destinationPath, `${baseName}.sql.gz`) : sqlFile;
// Create backup run record
const [runId] = await db('database_backup_runs').insert({
started_at: startTime,
status: 'running',
backup_type: this.dbType,
destination_path: finalFile
});
backupRun = { id: runId };
// Get initial checksums
let tableChecksums = null;
if (includeChecksums) {
this.updateProgress('Calculating table checksums...');
tableChecksums = await this.getTableChecksums();
}
// Get database size
const dbSize = await this.getDatabaseSize();
// Create the backup
this.updateProgress('Creating database backup...');
if (this.dbType === 'sqlite') {
await this.createSQLiteBackup(sqlFile, options);
} else {
await this.createPostgreSQLBackup(sqlFile, options);
}
// Compress if requested
let compressionStats = null;
if (compress) {
this.updateProgress('Compressing backup...');
compressionStats = await this.compressFile(sqlFile, finalFile);
await fs.unlink(sqlFile); // Remove uncompressed file
}
// Calculate checksum
this.updateProgress('Calculating backup checksum...');
const backupChecksum = await this.calculateChecksum(finalFile);
// Validate if requested
if (validateIntegrity && !compress) {
this.updateProgress('Validating backup integrity...');
await this.validateBackup(finalFile, tableChecksums);
}
// Get final file size
const finalStats = await fs.stat(finalFile);
// Calculate duration
const endTime = new Date();
const durationSeconds = Math.round((endTime - startTime) / 1000);
// Update backup run record
await db('database_backup_runs')
.where('id', runId)
.update({
completed_at: endTime,
status: 'completed',
file_path: finalFile,
file_size_bytes: finalStats.size,
original_size_bytes: dbSize,
duration_seconds: durationSeconds,
checksum: backupChecksum,
compression_ratio: compressionStats?.compressionRatio || null,
table_checksums: tableChecksums ? JSON.stringify(tableChecksums) : null,
statistics: JSON.stringify({
dbType: this.dbType,
compressed: compress,
validated: validateIntegrity,
compressionStats,
tableCount: tableChecksums ? Object.keys(tableChecksums).length : null
})
});
logger.info(`Database backup completed: ${finalFile} (${(finalStats.size / 1024 / 1024).toFixed(2)} MB) in ${durationSeconds}s`);
// Send success notification if configured
if (config.emailOnSuccess) {
await this.sendBackupNotification('success', {
duration: durationSeconds,
size: finalStats.size,
compressionRatio: compressionStats?.compressionRatio,
path: finalFile
});
}
return {
success: true,
path: finalFile,
size: finalStats.size,
duration: durationSeconds,
checksum: backupChecksum,
compressionRatio: compressionStats?.compressionRatio
};
} catch (error) {
logger.error('Database backup failed:', error);
// Update backup run record
if (backupRun) {
await db('database_backup_runs')
.where('id', backupRun.id)
.update({
completed_at: new Date(),
status: 'failed',
error_message: error.message
});
}
// Send failure notification
const config = await this.getBackupConfig();
if (config.emailOnFailure) {
await this.sendBackupNotification('failure', {
error: error.message
});
}
throw error;
} finally {
this.isRunning = false;
this.currentProgress = null;
}
}
/**
* Update progress
*/
updateProgress(message, details = {}) {
this.currentProgress = {
message,
details,
timestamp: new Date()
};
logger.info(`Backup progress: ${message}`, details);
}
/**
* Get current progress
*/
getProgress() {
return this.currentProgress;
}
/**
* Get backup configuration
*/
async getBackupConfig() {
const settings = await db('app_settings')
.where('setting_type', 'database_backup')
.select('setting_key', 'setting_value');
const config = {};
settings.forEach(setting => {
try {
config[setting.setting_key] = JSON.parse(setting.setting_value);
} catch (e) {
config[setting.setting_key] = setting.setting_value;
}
});
return config;
}
/**
* Send backup notification email
*/
async sendBackupNotification(type, details) {
const admins = await db('admin_users').where('is_active', formatBoolean(true));
for (const admin of admins) {
if (type === 'success') {
await queueEmail(null, admin.email, 'database_backup_completed', {
backup_type: this.dbType,
duration: `${details.duration} seconds`,
file_size: `${(details.size / 1024 / 1024).toFixed(2)} MB`,
compression_ratio: details.compressionRatio ? `${details.compressionRatio}%` : 'N/A',
file_path: details.path
});
} else {
await queueEmail(null, admin.email, 'database_backup_failed', {
backup_type: this.dbType,
error_message: details.error,
timestamp: new Date().toISOString()
});
}
}
}
/**
* Clean up old backups
*/
async cleanupOldBackups(retentionDays = 30) {
try {
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
// Get old backup records
const oldBackups = await db('database_backup_runs')
.where('completed_at', '<', cutoffDate)
.where('status', 'completed')
.select('id', 'file_path');
let deletedCount = 0;
for (const backup of oldBackups) {
try {
// Delete the file
if (backup.file_path) {
await fs.unlink(backup.file_path);
}
// Delete the record
await db('database_backup_runs')
.where('id', backup.id)
.delete();
deletedCount++;
} catch (error) {
logger.error(`Failed to delete old backup ${backup.file_path}:`, error);
}
}
if (deletedCount > 0) {
logger.info(`Cleaned up ${deletedCount} old database backups`);
}
// Also clean up old failed runs
await db('database_backup_runs')
.where('started_at', '<', cutoffDate)
.where('status', 'failed')
.delete();
} catch (error) {
logger.error('Failed to cleanup old database backups:', error);
}
}
/**
* Get backup history
*/
async getBackupHistory(limit = 10) {
return await db('database_backup_runs')
.orderBy('started_at', 'desc')
.limit(limit);
}
/**
* Restore from backup (careful!)
*/
async restore(backupPath, options = {}) {
// This is a dangerous operation and should be used with extreme caution
throw new Error('Restore functionality not implemented for safety. Please restore manually.');
}
}
// Create singleton instance
const databaseBackupService = new DatabaseBackupService();
// Scheduled backup runner
let backupSchedule = null;
/**
* Start scheduled database backups
*/
async function startScheduledBackups() {
const cron = require('node-cron');
try {
const config = await databaseBackupService.getBackupConfig();
if (!config.enabled) {
logger.info('Database backup service is disabled');
return;
}
// Stop existing schedule
if (backupSchedule) {
backupSchedule.stop();
}
// Default schedule: 3 AM daily (offset from file backups at 2 AM)
const schedule = config.schedule || '0 3 * * *';
backupSchedule = cron.schedule(schedule, async () => {
logger.info('Starting scheduled database backup');
try {
await databaseBackupService.backup();
await databaseBackupService.cleanupOldBackups(config.retentionDays || 30);
} catch (error) {
logger.error('Scheduled database backup failed:', error);
}
});
logger.info(`Database backup service started with schedule: ${schedule}`);
} catch (error) {
logger.error('Failed to start database backup service:', error);
}
}
/**
* Stop scheduled database backups
*/
function stopScheduledBackups() {
if (backupSchedule) {
backupSchedule.stop();
backupSchedule = null;
logger.info('Database backup service stopped');
}
}
module.exports = {
databaseBackupService,
startScheduledBackups,
stopScheduledBackups,
DatabaseBackupService // Export class for testing
};
+34 -4
View File
@@ -109,6 +109,10 @@ async function getRecipientLanguage(email, eventId = null) {
// 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';
@@ -118,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 = '';
@@ -156,10 +186,10 @@ async function processTemplate(template, variables, language = 'en') {
const htmlTemplate = Handlebars.compile(htmlBody);
const textTemplate = Handlebars.compile(textBody);
// Process templates with variables
subject = subjectTemplate(variables);
htmlBody = htmlTemplate(variables);
textBody = textTemplate(variables);
// 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 = `
+2 -1
View File
@@ -60,6 +60,7 @@ async function processEmailQueue() {
}
// Start email queue processor
setInterval(processEmailQueue, 60000); // Process every minute
// DISABLED: Using emailProcessor.js instead to prevent duplicate connections
// setInterval(processEmailQueue, 60000); // Process every minute
module.exports = { sendEmail, processEmailQueue };
+7
View File
@@ -51,6 +51,13 @@ 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: formatBoolean(true) }).first();
if (!event) return;
+92 -5
View File
@@ -1,6 +1,7 @@
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
@@ -10,7 +11,7 @@ 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();
@@ -19,11 +20,29 @@ async function generateThumbnail(imagePath) {
// Ensure thumbnail directory exists
await fs.mkdir(thumbnailDir, { recursive: true });
// 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
}
}
try {
// Generate thumbnail with memory-efficient settings
// 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
sequentialRead: true, // More memory efficient for large images
failOnError: false // Don't fail on minor issues
})
.resize(THUMBNAIL_WIDTH, null, {
withoutEnlargement: true,
@@ -36,12 +55,80 @@ async function generateThumbnail(imagePath) {
})
.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) {
console.error(`Failed to generate thumbnail for ${filename}:`, 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
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,311 @@
const S3StorageAdapter = require('../s3Storage');
const { S3Client } = require('@aws-sdk/client-s3');
const { Upload } = require('@aws-sdk/lib-storage');
const fs = require('fs');
const stream = require('stream');
// Mock AWS SDK
jest.mock('@aws-sdk/client-s3');
jest.mock('@aws-sdk/lib-storage');
jest.mock('@aws-sdk/s3-request-presigner');
describe('S3StorageAdapter', () => {
let mockS3Client;
let mockSend;
let s3Storage;
beforeEach(() => {
// Reset mocks
jest.clearAllMocks();
// Mock S3Client
mockSend = jest.fn();
mockS3Client = {
send: mockSend
};
S3Client.mockImplementation(() => mockS3Client);
// Create adapter instance
s3Storage = new S3StorageAdapter({
bucket: 'test-bucket',
region: 'us-east-1',
accessKeyId: 'test-key',
secretAccessKey: 'test-secret'
});
});
describe('constructor', () => {
it('should initialize with required config', () => {
expect(s3Storage.bucket).toBe('test-bucket');
expect(s3Storage.config.region).toBe('us-east-1');
});
it('should throw error if bucket is not provided', () => {
expect(() => {
new S3StorageAdapter({ region: 'us-east-1' });
}).toThrow('S3 bucket name is required');
});
it('should configure for MinIO with path style', () => {
const minioStorage = new S3StorageAdapter({
bucket: 'test-bucket',
endpoint: 'http://localhost:9000',
forcePathStyle: true,
sslEnabled: false
});
expect(S3Client).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: 'http://localhost:9000',
forcePathStyle: true
})
);
});
});
describe('testConnection', () => {
it('should successfully test connection', async () => {
mockSend.mockResolvedValueOnce({});
const result = await s3Storage.testConnection();
expect(result).toBe(true);
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
input: { Bucket: 'test-bucket' }
})
);
});
it('should throw error on connection failure', async () => {
mockSend.mockRejectedValueOnce(new Error('Access Denied'));
await expect(s3Storage.testConnection()).rejects.toThrow('S3 connection test failed');
});
});
describe('upload', () => {
let mockUpload;
let mockDone;
beforeEach(() => {
mockDone = jest.fn().mockResolvedValue({
Location: 'https://test-bucket.s3.amazonaws.com/test-key',
ETag: '"test-etag"'
});
mockUpload = {
on: jest.fn().mockReturnThis(),
done: mockDone
};
Upload.mockImplementation(() => mockUpload);
// Mock fs.stat
jest.spyOn(fs.promises, 'stat').mockResolvedValue({
size: 1024
});
// Mock fs.createReadStream
jest.spyOn(fs, 'createReadStream').mockReturnValue(new stream.Readable());
});
afterEach(() => {
jest.restoreAllMocks();
});
it('should upload file successfully', async () => {
const result = await s3Storage.upload('/path/to/file.jpg', 'test-key');
expect(result.Location).toBe('https://test-bucket.s3.amazonaws.com/test-key');
expect(Upload).toHaveBeenCalledWith(
expect.objectContaining({
client: mockS3Client,
params: expect.objectContaining({
Bucket: 'test-bucket',
Key: 'test-key',
ContentType: 'application/octet-stream'
})
})
);
});
it('should track upload progress', async () => {
const onProgress = jest.fn();
let progressCallback;
mockUpload.on.mockImplementation((event, callback) => {
if (event === 'httpUploadProgress') {
progressCallback = callback;
}
return mockUpload;
});
const uploadPromise = s3Storage.upload('/path/to/file.jpg', 'test-key', {
onProgress
});
// Simulate progress
progressCallback({ loaded: 512, total: 1024 });
await uploadPromise;
expect(onProgress).toHaveBeenCalledWith(512, 1024);
});
it('should emit upload events', async () => {
const uploadStartSpy = jest.fn();
const uploadCompleteSpy = jest.fn();
s3Storage.on('uploadStart', uploadStartSpy);
s3Storage.on('uploadComplete', uploadCompleteSpy);
await s3Storage.upload('/path/to/file.jpg', 'test-key');
expect(uploadStartSpy).toHaveBeenCalledWith({ key: 'test-key', size: 1024 });
expect(uploadCompleteSpy).toHaveBeenCalledWith({
key: 'test-key',
location: 'https://test-bucket.s3.amazonaws.com/test-key'
});
});
});
describe('exists', () => {
it('should return true if object exists', async () => {
mockSend.mockResolvedValueOnce({});
const result = await s3Storage.exists('test-key');
expect(result).toBe(true);
expect(mockSend).toHaveBeenCalledWith(
expect.objectContaining({
input: { Bucket: 'test-bucket', Key: 'test-key' }
})
);
});
it('should return false if object does not exist', async () => {
const error = new Error('Not Found');
error.name = 'NotFound';
error.$metadata = { httpStatusCode: 404 };
mockSend.mockRejectedValueOnce(error);
const result = await s3Storage.exists('test-key');
expect(result).toBe(false);
});
});
describe('generateKey', () => {
it('should generate unique key with timestamp and random string', () => {
const key = s3Storage.generateKey('photo.jpg');
expect(key).toMatch(/^\d+_[a-f0-9]{16}_photo\.jpg$/);
});
it('should add prefix if provided', () => {
const key = s3Storage.generateKey('photo.jpg', 'events/wedding');
expect(key).toMatch(/^events\/wedding\/\d+_[a-f0-9]{16}_photo\.jpg$/);
});
it('should sanitize filename', () => {
const key = s3Storage.generateKey('my photo (1).jpg');
expect(key).toMatch(/^\d+_[a-f0-9]{16}_my_photo__1_\.jpg$/);
});
});
describe('retry logic', () => {
it('should retry on retryable errors', async () => {
const retryableError = new Error('Connection reset');
retryableError.code = 'ECONNRESET';
// First attempt fails, second succeeds
mockSend
.mockRejectedValueOnce(retryableError)
.mockResolvedValueOnce({});
// Mock setTimeout to speed up test
jest.useFakeTimers();
const promise = s3Storage.exists('test-key');
// Advance timers
jest.runAllTimers();
const result = await promise;
expect(result).toBe(true);
expect(mockSend).toHaveBeenCalledTimes(2);
jest.useRealTimers();
});
it('should not retry on non-retryable errors', async () => {
const nonRetryableError = new Error('Invalid credentials');
nonRetryableError.code = 'InvalidCredentials';
mockSend.mockRejectedValueOnce(nonRetryableError);
await expect(s3Storage.exists('test-key')).rejects.toThrow('Invalid credentials');
expect(mockSend).toHaveBeenCalledTimes(1);
});
it('should stop retrying after max attempts', async () => {
const retryableError = new Error('Service unavailable');
retryableError.code = 'ServiceUnavailable';
mockSend.mockRejectedValue(retryableError);
// Mock setTimeout to speed up test
jest.useFakeTimers();
const promise = s3Storage.exists('test-key');
// Advance timers for all retries
for (let i = 0; i < 4; i++) {
jest.runAllTimers();
}
await expect(promise).rejects.toThrow('Service unavailable');
expect(mockSend).toHaveBeenCalledTimes(4); // Initial + 3 retries
jest.useRealTimers();
});
});
describe('getStats', () => {
it('should calculate storage statistics', async () => {
mockSend.mockResolvedValueOnce({
Contents: [
{ Key: 'file1.jpg', Size: 1024 },
{ Key: 'file2.jpg', Size: 2048 }
],
NextContinuationToken: 'token123'
}).mockResolvedValueOnce({
Contents: [
{ Key: 'file3.jpg', Size: 3072 }
]
});
const stats = await s3Storage.getStats('events/');
expect(stats).toEqual({
totalSize: 6144,
totalCount: 3,
totalSizeFormatted: '6 KB'
});
});
});
describe('_formatBytes', () => {
it('should format bytes correctly', () => {
expect(s3Storage._formatBytes(0)).toBe('0 Bytes');
expect(s3Storage._formatBytes(1024)).toBe('1 KB');
expect(s3Storage._formatBytes(1048576)).toBe('1 MB');
expect(s3Storage._formatBytes(1073741824)).toBe('1 GB');
expect(s3Storage._formatBytes(1536, 1)).toBe('1.5 KB');
});
});
});
@@ -0,0 +1,221 @@
/**
* Example usage of the S3StorageAdapter
*
* This file demonstrates how to use the S3 storage adapter for various operations
*/
const S3StorageAdapter = require('./s3Storage');
// Example 1: Basic AWS S3 Configuration
const s3Storage = new S3StorageAdapter({
bucket: 'my-photo-bucket',
region: 'us-east-1',
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
});
// Example 2: MinIO Configuration (S3-compatible)
const minioStorage = new S3StorageAdapter({
bucket: 'photo-storage',
endpoint: 'http://localhost:9000', // MinIO endpoint
accessKeyId: 'minioadmin',
secretAccessKey: 'minioadmin',
forcePathStyle: true, // Required for MinIO
sslEnabled: false // For local development
});
// Example 3: DigitalOcean Spaces Configuration
const spacesStorage = new S3StorageAdapter({
bucket: 'my-space-name',
endpoint: 'https://nyc3.digitaloceanspaces.com',
region: 'nyc3',
accessKeyId: process.env.DO_SPACES_KEY,
secretAccessKey: process.env.DO_SPACES_SECRET
});
// Usage Examples
async function examples() {
try {
// Test connection
await s3Storage.testConnection();
console.log('Connection successful!');
// Upload a file with progress tracking
const uploadResult = await s3Storage.upload(
'/path/to/local/photo.jpg',
'events/wedding-2024/photo.jpg',
{
contentType: 'image/jpeg',
metadata: {
event: 'wedding-2024',
photographer: 'John Doe'
},
onProgress: (loaded, total) => {
const percentage = Math.round((loaded / total) * 100);
console.log(`Upload progress: ${percentage}%`);
}
}
);
console.log('Upload complete:', uploadResult.Location);
// Upload from stream
const readStream = fs.createReadStream('/path/to/large-video.mp4');
await s3Storage.uploadStream(readStream, 'events/wedding-2024/video.mp4', {
contentType: 'video/mp4',
onProgress: (loaded, total) => {
console.log(`Streamed ${loaded} of ${total} bytes`);
}
});
// Download a file
await s3Storage.download(
'events/wedding-2024/photo.jpg',
'/path/to/downloaded/photo.jpg',
{
onProgress: (loaded, total) => {
const percentage = Math.round((loaded / total) * 100);
console.log(`Download progress: ${percentage}%`);
}
}
);
// Get a download stream
const downloadStream = await s3Storage.downloadStream('events/wedding-2024/photo.jpg');
downloadStream.pipe(fs.createWriteStream('/path/to/output.jpg'));
// List files
const listing = await s3Storage.list('events/wedding-2024/');
console.log(`Found ${listing.Contents.length} files`);
listing.Contents.forEach(file => {
console.log(`- ${file.Key} (${file.Size} bytes)`);
});
// Generate pre-signed URL for temporary access
const downloadUrl = await s3Storage.getSignedUrl('getObject', 'events/wedding-2024/photo.jpg', {
expiresIn: 3600 // 1 hour
});
console.log('Pre-signed download URL:', downloadUrl);
// Generate pre-signed upload URL
const uploadUrl = await s3Storage.getSignedUrl('putObject', 'events/wedding-2024/new-photo.jpg', {
expiresIn: 1800, // 30 minutes
params: {
ContentType: 'image/jpeg'
}
});
console.log('Pre-signed upload URL:', uploadUrl);
// Check if file exists
const exists = await s3Storage.exists('events/wedding-2024/photo.jpg');
console.log('File exists:', exists);
// Get metadata
const metadata = await s3Storage.getMetadata('events/wedding-2024/photo.jpg');
console.log('File metadata:', metadata);
// Copy file
await s3Storage.copy(
'events/wedding-2024/photo.jpg',
'events/wedding-2024/photo-copy.jpg'
);
// Move file
await s3Storage.move(
'events/wedding-2024/photo-copy.jpg',
'events/wedding-2024/archived/photo.jpg'
);
// Delete file
await s3Storage.delete('events/wedding-2024/temp-photo.jpg');
// Delete multiple files
const deleteResult = await s3Storage.deleteMany([
'events/wedding-2024/temp1.jpg',
'events/wedding-2024/temp2.jpg',
'events/wedding-2024/temp3.jpg'
]);
console.log(`Deleted ${deleteResult.Deleted.length} files`);
// Get storage statistics
const stats = await s3Storage.getStats('events/');
console.log(`Total files: ${stats.totalCount}`);
console.log(`Total size: ${stats.totalSizeFormatted}`);
// Listen to events
s3Storage.on('uploadProgress', (data) => {
console.log(`Uploading ${data.key}: ${data.loaded}/${data.total}`);
});
s3Storage.on('uploadComplete', (data) => {
console.log(`Upload completed: ${data.key}`);
});
s3Storage.on('uploadError', (data) => {
console.error(`Upload failed for ${data.key}:`, data.error);
});
} catch (error) {
console.error('Error:', error);
}
}
// Integration with existing photo upload workflow
async function integrateWithPhotoUpload(eventId, files) {
const storage = new S3StorageAdapter({
bucket: process.env.S3_BUCKET,
region: process.env.AWS_REGION,
accessKeyId: process.env.AWS_ACCESS_KEY_ID,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY
});
const uploadedPhotos = [];
for (const file of files) {
try {
// Generate unique S3 key
const s3Key = storage.generateKey(file.originalname, `events/${eventId}`);
// Upload to S3
const result = await storage.upload(file.path, s3Key, {
contentType: file.mimetype,
metadata: {
eventId: eventId,
originalName: file.originalname,
uploadedAt: new Date().toISOString()
}
});
uploadedPhotos.push({
filename: s3Key,
originalName: file.originalname,
size: file.size,
mimeType: file.mimetype,
s3Location: result.Location,
s3Key: s3Key
});
// Clean up local temp file
await fs.promises.unlink(file.path);
} catch (error) {
console.error(`Failed to upload ${file.originalname}:`, error);
throw error;
}
}
return uploadedPhotos;
}
// Environment variables needed:
// AWS_ACCESS_KEY_ID=your-access-key
// AWS_SECRET_ACCESS_KEY=your-secret-key
// AWS_REGION=us-east-1
// S3_BUCKET=your-bucket-name
// For MinIO:
// MINIO_ENDPOINT=http://localhost:9000
// MINIO_ACCESS_KEY=minioadmin
// MINIO_SECRET_KEY=minioadmin
// MINIO_BUCKET=photo-storage
module.exports = { examples, integrateWithPhotoUpload };
+733
View File
@@ -0,0 +1,733 @@
const { S3Client, HeadBucketCommand, HeadObjectCommand, GetObjectCommand, PutObjectCommand, DeleteObjectCommand, DeleteObjectsCommand, ListObjectsV2Command, CopyObjectCommand, CreateMultipartUploadCommand, UploadPartCommand, CompleteMultipartUploadCommand, AbortMultipartUploadCommand } = require('@aws-sdk/client-s3');
const { Upload } = require('@aws-sdk/lib-storage');
const { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
const fs = require('fs');
const fsPromises = require('fs').promises;
const path = require('path');
const stream = require('stream');
const crypto = require('crypto');
const logger = require('../../utils/logger');
/**
* S3 Storage Adapter for handling file uploads to S3 and S3-compatible services
*
* Features:
* - Support for S3 and S3-compatible services (MinIO, DigitalOcean Spaces, etc.)
* - Multipart upload for large files (>100MB)
* - Progress tracking with event emitter
* - Retry logic with exponential backoff
* - Stream support for memory efficiency
* - Path-style URL support for MinIO
* - Connection testing
* - Comprehensive error handling
*
* @class S3StorageAdapter
*/
class S3StorageAdapter extends stream.EventEmitter {
/**
* Creates an instance of S3StorageAdapter
*
* @param {Object} config - Configuration object
* @param {string} config.bucket - S3 bucket name
* @param {string} [config.region='us-east-1'] - AWS region
* @param {string} [config.endpoint] - Custom endpoint URL for S3-compatible services
* @param {string} [config.accessKeyId] - AWS access key ID
* @param {string} [config.secretAccessKey] - AWS secret access key
* @param {boolean} [config.forcePathStyle=false] - Force path-style URLs (required for MinIO)
* @param {boolean} [config.sslEnabled=true] - Enable SSL for connections
* @param {number} [config.multipartThreshold=104857600] - Threshold for multipart upload (default 100MB)
* @param {number} [config.partSize=10485760] - Part size for multipart upload (default 10MB)
* @param {number} [config.maxRetries=3] - Maximum number of retry attempts
* @param {number} [config.retryDelay=1000] - Initial retry delay in milliseconds
*/
constructor(config) {
super();
// Validate required config
if (!config.bucket) {
throw new Error('S3 bucket name is required');
}
// Set defaults
this.config = {
region: 'us-east-1',
forcePathStyle: false,
sslEnabled: true,
multipartThreshold: 100 * 1024 * 1024, // 100MB
partSize: 10 * 1024 * 1024, // 10MB
maxRetries: 3,
retryDelay: 1000,
...config
};
// Initialize S3 client
const s3Config = {
region: this.config.region,
forcePathStyle: this.config.forcePathStyle
};
// Add credentials if provided
if (this.config.accessKeyId && this.config.secretAccessKey) {
s3Config.credentials = {
accessKeyId: this.config.accessKeyId,
secretAccessKey: this.config.secretAccessKey
};
}
// Add custom endpoint if provided (for S3-compatible services)
if (this.config.endpoint) {
s3Config.endpoint = this.config.endpoint;
// For MinIO and other S3-compatible services
if (!this.config.endpoint.startsWith('https://') && this.config.sslEnabled) {
s3Config.endpoint = `https://${this.config.endpoint}`;
} else if (!this.config.endpoint.startsWith('http://') && !this.config.sslEnabled) {
s3Config.endpoint = `http://${this.config.endpoint}`;
}
}
this.s3Client = new S3Client(s3Config);
this.bucket = this.config.bucket;
// Bind methods to preserve context
this.upload = this.upload.bind(this);
this.uploadStream = this.uploadStream.bind(this);
this.download = this.download.bind(this);
this.downloadStream = this.downloadStream.bind(this);
this.delete = this.delete.bind(this);
this.exists = this.exists.bind(this);
this.list = this.list.bind(this);
this.copy = this.copy.bind(this);
this.move = this.move.bind(this);
this.getSignedUrl = this.getSignedUrl.bind(this);
this.testConnection = this.testConnection.bind(this);
}
/**
* Test connection to S3 bucket
*
* @returns {Promise<boolean>} - True if connection successful
* @throws {Error} - If connection fails
*/
async testConnection() {
try {
await this.s3Client.send(new HeadBucketCommand({ Bucket: this.bucket }));
logger.info(`Successfully connected to S3 bucket: ${this.bucket}`);
return true;
} catch (error) {
logger.error(`Failed to connect to S3 bucket ${this.bucket}:`, error);
throw new Error(`S3 connection test failed: ${error.message}`);
}
}
/**
* Upload a file to S3 with automatic multipart for large files
*
* @param {string} localPath - Local file path to upload
* @param {string} s3Key - S3 object key (path in bucket)
* @param {Object} [options={}] - Additional options
* @param {Object} [options.metadata] - Object metadata
* @param {string} [options.contentType] - Content type
* @param {string} [options.cacheControl] - Cache control header
* @param {Function} [options.onProgress] - Progress callback function(loaded, total)
* @returns {Promise<Object>} - Upload result with Location, ETag, etc.
*/
async upload(localPath, s3Key, options = {}) {
try {
const stats = await fsPromises.stat(localPath);
const fileSize = stats.size;
// Emit upload start event
this.emit('uploadStart', { key: s3Key, size: fileSize });
// Create file stream
const fileStream = fs.createReadStream(localPath);
// Prepare upload parameters
const uploadParams = {
Bucket: this.bucket,
Key: s3Key,
Body: fileStream,
ContentType: options.contentType || 'application/octet-stream',
Metadata: options.metadata || {},
CacheControl: options.cacheControl
};
// Remove undefined values
Object.keys(uploadParams).forEach(key => uploadParams[key] === undefined && delete uploadParams[key]);
// Use AWS SDK v3 Upload for automatic multipart handling
const parallelUploads3 = new Upload({
client: this.s3Client,
params: uploadParams,
queueSize: 4, // Optional: concurrent uploads
partSize: this.config.partSize,
leavePartsOnError: false
});
// Track upload progress
parallelUploads3.on('httpUploadProgress', (progress) => {
if (options.onProgress) {
options.onProgress(progress.loaded, progress.total);
}
this.emit('uploadProgress', { key: s3Key, loaded: progress.loaded, total: progress.total });
});
// Perform upload with retry
const result = await this._retryOperation(() => parallelUploads3.done());
this.emit('uploadComplete', { key: s3Key, location: result.Location });
return result;
} catch (error) {
logger.error(`Failed to upload file ${localPath} to S3:`, error);
this.emit('uploadError', { key: s3Key, error });
throw error;
}
}
/**
* Upload a stream to S3
*
* @param {stream.Readable} readStream - Readable stream to upload
* @param {string} s3Key - S3 object key
* @param {Object} [options={}] - Additional options
* @returns {Promise<Object>} - Upload result
*/
async uploadStream(readStream, s3Key, options = {}) {
const uploadParams = {
Bucket: this.bucket,
Key: s3Key,
Body: readStream,
ContentType: options.contentType || 'application/octet-stream',
Metadata: options.metadata || {},
CacheControl: options.cacheControl
};
// Remove undefined values
Object.keys(uploadParams).forEach(key => uploadParams[key] === undefined && delete uploadParams[key]);
const parallelUploads3 = new Upload({
client: this.s3Client,
params: uploadParams,
queueSize: 4,
partSize: this.config.partSize,
leavePartsOnError: false
});
// Track progress if callback provided
if (options.onProgress) {
parallelUploads3.on('httpUploadProgress', (progress) => {
options.onProgress(progress.loaded, progress.total);
this.emit('uploadProgress', { key: s3Key, ...progress });
});
}
return await this._retryOperation(() => parallelUploads3.done());
}
/**
* Download a file from S3
*
* @param {string} s3Key - S3 object key
* @param {string} localPath - Local file path to save to
* @param {Object} [options={}] - Additional options
* @param {Function} [options.onProgress] - Progress callback
* @returns {Promise<void>}
*/
async download(s3Key, localPath, options = {}) {
try {
// Ensure directory exists
await fsPromises.mkdir(path.dirname(localPath), { recursive: true });
// Get object metadata first for progress tracking
const headResult = await this._retryOperation(() =>
this.s3Client.send(new HeadObjectCommand({
Bucket: this.bucket,
Key: s3Key
}))
);
const fileSize = headResult.ContentLength;
this.emit('downloadStart', { key: s3Key, size: fileSize });
// Get object
const getObjectResult = await this._retryOperation(() =>
this.s3Client.send(new GetObjectCommand({
Bucket: this.bucket,
Key: s3Key
}))
);
// Create write stream
const writeStream = fs.createWriteStream(localPath);
// Download with progress tracking
await new Promise((resolve, reject) => {
let downloaded = 0;
const bodyStream = getObjectResult.Body;
bodyStream.on('data', (chunk) => {
downloaded += chunk.length;
if (options.onProgress) {
options.onProgress(downloaded, fileSize);
}
this.emit('downloadProgress', { key: s3Key, loaded: downloaded, total: fileSize });
});
bodyStream.on('error', reject);
writeStream.on('error', reject);
writeStream.on('finish', resolve);
bodyStream.pipe(writeStream);
});
this.emit('downloadComplete', { key: s3Key });
} catch (error) {
logger.error(`Failed to download file ${s3Key} from S3:`, error);
this.emit('downloadError', { key: s3Key, error });
throw error;
}
}
/**
* Get a download stream from S3
*
* @param {string} s3Key - S3 object key
* @param {Object} [options={}] - Additional options
* @param {string} [options.range] - Byte range to download (e.g., 'bytes=0-1023')
* @returns {Promise<stream.Readable>} - Readable stream
*/
async downloadStream(s3Key, options = {}) {
const params = {
Bucket: this.bucket,
Key: s3Key,
Range: options.range
};
// Remove undefined values
Object.keys(params).forEach(key => params[key] === undefined && delete params[key]);
const result = await this.s3Client.send(new GetObjectCommand(params));
return result.Body;
}
/**
* Delete a file from S3
*
* @param {string} s3Key - S3 object key
* @returns {Promise<void>}
*/
async delete(s3Key) {
return await this._retryOperation(() =>
this.s3Client.send(new DeleteObjectCommand({
Bucket: this.bucket,
Key: s3Key
}))
);
}
/**
* Delete multiple files from S3
*
* @param {string[]} s3Keys - Array of S3 object keys
* @returns {Promise<Object>} - Delete result
*/
async deleteMany(s3Keys) {
if (!s3Keys || s3Keys.length === 0) {
return { Deleted: [], Errors: [] };
}
// S3 deleteObjects has a limit of 1000 keys per request
const chunks = [];
for (let i = 0; i < s3Keys.length; i += 1000) {
chunks.push(s3Keys.slice(i, i + 1000));
}
const results = await Promise.all(
chunks.map(chunk =>
this._retryOperation(() =>
this.s3Client.send(new DeleteObjectsCommand({
Bucket: this.bucket,
Delete: {
Objects: chunk.map(key => ({ Key: key })),
Quiet: false
}
}))
)
)
);
// Combine results
return {
Deleted: results.flatMap(r => r.Deleted || []),
Errors: results.flatMap(r => r.Errors || [])
};
}
/**
* Check if a file exists in S3
*
* @param {string} s3Key - S3 object key
* @returns {Promise<boolean>} - True if exists
*/
async exists(s3Key) {
try {
await this.s3Client.send(new HeadObjectCommand({
Bucket: this.bucket,
Key: s3Key
}));
return true;
} catch (error) {
if (error.name === 'NotFound' || error.$metadata?.httpStatusCode === 404) {
return false;
}
throw error;
}
}
/**
* List files in S3
*
* @param {string} prefix - S3 prefix to list
* @param {Object} [options={}] - Additional options
* @param {number} [options.maxKeys=1000] - Maximum number of keys to return
* @param {string} [options.continuationToken] - Continuation token for pagination
* @returns {Promise<Object>} - List result with Contents array and NextContinuationToken
*/
async list(prefix, options = {}) {
const params = {
Bucket: this.bucket,
Prefix: prefix,
MaxKeys: options.maxKeys || 1000,
ContinuationToken: options.continuationToken
};
return await this._retryOperation(() =>
this.s3Client.send(new ListObjectsV2Command(params))
);
}
/**
* Copy a file within S3
*
* @param {string} sourceKey - Source S3 object key
* @param {string} targetKey - Target S3 object key
* @param {Object} [options={}] - Additional options
* @returns {Promise<Object>} - Copy result
*/
async copy(sourceKey, targetKey, options = {}) {
const copySource = `${this.bucket}/${sourceKey}`;
const params = {
Bucket: this.bucket,
CopySource: copySource,
Key: targetKey,
MetadataDirective: options.metadata ? 'REPLACE' : 'COPY',
Metadata: options.metadata,
ContentType: options.contentType,
CacheControl: options.cacheControl
};
// Remove undefined values
Object.keys(params).forEach(key => params[key] === undefined && delete params[key]);
return await this._retryOperation(() =>
this.s3Client.send(new CopyObjectCommand(params))
);
}
/**
* Move a file within S3 (copy then delete)
*
* @param {string} sourceKey - Source S3 object key
* @param {string} targetKey - Target S3 object key
* @param {Object} [options={}] - Additional options
* @returns {Promise<Object>} - Move result
*/
async move(sourceKey, targetKey, options = {}) {
// First copy the object
const copyResult = await this.copy(sourceKey, targetKey, options);
// Then delete the original
await this.delete(sourceKey);
return copyResult;
}
/**
* Get a pre-signed URL for downloading or uploading
*
* @param {string} operation - Operation type ('getObject' or 'putObject')
* @param {string} s3Key - S3 object key
* @param {Object} [options={}] - Additional options
* @param {number} [options.expiresIn=3600] - URL expiration in seconds
* @param {Object} [options.params] - Additional parameters for the operation
* @returns {Promise<string>} - Pre-signed URL
*/
async getSignedUrl(operation, s3Key, options = {}) {
const params = {
Bucket: this.bucket,
Key: s3Key,
...options.params
};
let command;
switch (operation.toLowerCase()) {
case 'getobject':
command = new GetObjectCommand(params);
break;
case 'putobject':
command = new PutObjectCommand(params);
break;
default:
throw new Error(`Unsupported operation: ${operation}`);
}
return await getSignedUrl(this.s3Client, command, {
expiresIn: options.expiresIn || 3600
});
}
/**
* Get object metadata
*
* @param {string} s3Key - S3 object key
* @returns {Promise<Object>} - Object metadata
*/
async getMetadata(s3Key) {
return await this._retryOperation(() =>
this.s3Client.send(new HeadObjectCommand({
Bucket: this.bucket,
Key: s3Key
}))
);
}
/**
* Update object metadata
*
* @param {string} s3Key - S3 object key
* @param {Object} metadata - New metadata
* @returns {Promise<Object>} - Update result
*/
async updateMetadata(s3Key, metadata) {
// S3 requires copying the object to itself to update metadata
return await this.copy(s3Key, s3Key, { metadata });
}
/**
* Manual multipart upload for advanced use cases
*
* @param {string} localPath - Local file path
* @param {string} s3Key - S3 object key
* @param {number} fileSize - File size in bytes
* @param {Object} options - Upload options
* @returns {Promise<Object>} - Upload result
* @private
*/
async _manualMultipartUpload(localPath, s3Key, fileSize, options) {
logger.info(`Starting manual multipart upload for ${s3Key} (${fileSize} bytes)`);
// Initiate multipart upload
const multipartParams = {
Bucket: this.bucket,
Key: s3Key,
ContentType: options.contentType || 'application/octet-stream',
Metadata: options.metadata || {},
CacheControl: options.cacheControl
};
// Remove undefined values
Object.keys(multipartParams).forEach(key => multipartParams[key] === undefined && delete multipartParams[key]);
const multipart = await this._retryOperation(() =>
this.s3Client.send(new CreateMultipartUploadCommand(multipartParams))
);
const uploadId = multipart.UploadId;
const partSize = this.config.partSize;
const numParts = Math.ceil(fileSize / partSize);
let uploaded = 0;
const parts = [];
try {
// Upload parts
for (let partNum = 1; partNum <= numParts; partNum++) {
const start = (partNum - 1) * partSize;
const end = Math.min(start + partSize, fileSize);
const partStream = fs.createReadStream(localPath, {
start,
end: end - 1
});
const partParams = {
Bucket: this.bucket,
Key: s3Key,
PartNumber: partNum,
UploadId: uploadId,
Body: partStream
};
// Upload part with retry
const partResult = await this._retryOperation(() =>
this.s3Client.send(new UploadPartCommand(partParams))
);
parts.push({
ETag: partResult.ETag,
PartNumber: partNum
});
uploaded += (end - start);
if (options.onProgress) {
options.onProgress(uploaded, fileSize);
}
this.emit('uploadProgress', { key: s3Key, loaded: uploaded, total: fileSize });
logger.info(`Uploaded part ${partNum}/${numParts} for ${s3Key}`);
}
// Complete multipart upload
const completeParams = {
Bucket: this.bucket,
Key: s3Key,
UploadId: uploadId,
MultipartUpload: { Parts: parts }
};
const result = await this._retryOperation(() =>
this.s3Client.send(new CompleteMultipartUploadCommand(completeParams))
);
this.emit('uploadComplete', { key: s3Key, location: result.Location });
logger.info(`Completed multipart upload for ${s3Key}`);
return result;
} catch (error) {
// Abort multipart upload on error
logger.error(`Multipart upload failed for ${s3Key}, aborting:`, error);
try {
await this.s3Client.send(new AbortMultipartUploadCommand({
Bucket: this.bucket,
Key: s3Key,
UploadId: uploadId
}));
} catch (abortError) {
logger.error(`Failed to abort multipart upload:`, abortError);
}
throw error;
}
}
/**
* Retry operation with exponential backoff
* @private
*/
async _retryOperation(operation, retryCount = 0) {
try {
return await operation();
} catch (error) {
if (retryCount >= this.config.maxRetries) {
throw error;
}
// Check if error is retryable
const retryableErrors = ['ECONNRESET', 'ETIMEDOUT', 'ENOTFOUND', 'ESOCKETTIMEDOUT', 'RequestTimeout', 'SlowDown', 'ServiceUnavailable', 'InternalError'];
const isRetryable = retryableErrors.some(code =>
error.code === code ||
error.name === code ||
error.$metadata?.httpStatusCode === 503 ||
error.$metadata?.httpStatusCode === 500
);
if (!isRetryable) {
throw error;
}
// Calculate delay with exponential backoff and jitter
const delay = Math.min(
this.config.retryDelay * Math.pow(2, retryCount) + Math.random() * 1000,
30000 // Max 30 seconds
);
logger.warn(`Retrying operation after ${delay}ms (attempt ${retryCount + 1}/${this.config.maxRetries}):`, error.message);
await new Promise(resolve => setTimeout(resolve, delay));
return this._retryOperation(operation, retryCount + 1);
}
}
/**
* Generate a unique S3 key for a file
*
* @param {string} originalName - Original filename
* @param {string} [prefix=''] - Optional prefix for the key
* @returns {string} - Generated S3 key
*/
generateKey(originalName, prefix = '') {
const timestamp = Date.now();
const randomStr = crypto.randomBytes(8).toString('hex');
const ext = path.extname(originalName);
const basename = path.basename(originalName, ext);
// Sanitize basename
const safeName = basename.replace(/[^a-zA-Z0-9-_]/g, '_');
const key = `${timestamp}_${randomStr}_${safeName}${ext}`;
return prefix ? path.posix.join(prefix, key) : key;
}
/**
* Get storage statistics
*
* @param {string} [prefix=''] - Optional prefix to filter
* @returns {Promise<Object>} - Storage statistics
*/
async getStats(prefix = '') {
let totalSize = 0;
let totalCount = 0;
let continuationToken;
do {
const result = await this.list(prefix, { continuationToken });
if (result.Contents) {
totalCount += result.Contents.length;
totalSize += result.Contents.reduce((sum, obj) => sum + (obj.Size || 0), 0);
}
continuationToken = result.NextContinuationToken;
} while (continuationToken);
return {
totalSize,
totalCount,
totalSizeFormatted: this._formatBytes(totalSize)
};
}
/**
* Format bytes to human readable format
* @private
*/
_formatBytes(bytes, decimals = 2) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
}
}
module.exports = S3StorageAdapter;
+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) };
+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
};
+49 -28
View File
@@ -27,7 +27,28 @@ async function formatDate(date, language = 'en') {
}
}
const dateObj = date instanceof Date ? date : new Date(date);
// 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';
@@ -39,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);
+40
View File
@@ -0,0 +1,40 @@
/**
* Formatters for email content and other text transformations
*/
/**
* Convert plain text line breaks to HTML line breaks
* @param {string} text - The text to format
* @returns {string} - Text with HTML line breaks
*/
function nl2br(text) {
if (!text) return '';
// Normalize line endings
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
// Convert newlines to <br> tags
return text
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.join('<br />');
}
/**
* Format welcome message for email templates
* @param {string} message - The welcome message
* @returns {string} - Formatted message for HTML emails
*/
function formatWelcomeMessage(message) {
if (!message || message.trim() === '') {
return '';
}
return nl2br(message);
}
module.exports = {
nl2br,
formatWelcomeMessage
};
+77 -10
View File
@@ -1,31 +1,98 @@
const winston = require('winston');
const path = require('path');
const fs = require('fs');
// Ensure logs directory exists
const logDir = path.join(__dirname, '../../logs');
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir, { recursive: true });
}
// Custom format for production logs
const productionFormat = winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
winston.format.errors({ stack: true }),
winston.format.json(),
winston.format.printf(info => {
// Ensure all security events are properly formatted
if (info.level === 'warn' && (info.message.includes('rate limit') ||
info.message.includes('auth') ||
info.message.includes('login') ||
info.message.includes('JWT'))) {
return JSON.stringify({
timestamp: info.timestamp,
level: info.level,
message: info.message,
security: true,
...info
});
}
return JSON.stringify(info);
})
);
const logger = winston.createLogger({
level: process.env.LOG_LEVEL || 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.errors({ stack: true }),
winston.format.json()
),
format: productionFormat,
transports: [
new winston.transports.File({
filename: path.join(__dirname, '../../logs/error.log'),
level: 'error'
filename: path.join(logDir, 'error.log'),
level: 'error',
maxsize: 10 * 1024 * 1024, // 10MB
maxFiles: 5,
tailable: true
}),
new winston.transports.File({
filename: path.join(__dirname, '../../logs/combined.log')
filename: path.join(logDir, 'combined.log'),
maxsize: 50 * 1024 * 1024, // 50MB
maxFiles: 10,
tailable: true
}),
// Separate security log for authentication and rate limiting
new winston.transports.File({
filename: path.join(logDir, 'security.log'),
level: 'warn',
maxsize: 20 * 1024 * 1024, // 20MB
maxFiles: 10,
tailable: true,
format: winston.format.combine(
winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss.SSS' }),
winston.format.json(),
winston.format.printf(info => {
// Only log security-related warnings
if (info.message.includes('rate limit') ||
info.message.includes('auth') ||
info.message.includes('login') ||
info.message.includes('JWT') ||
info.message.includes('lockout') ||
info.message.includes('suspicious')) {
return JSON.stringify(info);
}
return null;
})
)
})
]
].filter(Boolean)
});
// Add console logging for non-production environments
if (process.env.NODE_ENV !== 'production') {
logger.add(new winston.transports.Console({
format: winston.format.combine(
winston.format.colorize(),
winston.format.simple()
winston.format.timestamp({ format: 'HH:mm:ss' }),
winston.format.printf(info => {
return `[${info.timestamp}] ${info.level}: ${info.message} ${info.stack || ''}`;
})
)
}));
} else {
// In production, also log to console for container environments
if (process.env.LOG_TO_CONSOLE === 'true') {
logger.add(new winston.transports.Console({
format: productionFormat
}));
}
}
module.exports = logger;
+88 -11
View File
@@ -113,6 +113,84 @@ function validatePassword(password, options = {}) {
};
}
/**
* Get complexity settings from database
* @returns {Object} - Password complexity configuration
*/
async function getPasswordComplexitySettings() {
try {
const { db, withRetry } = require('../database/db');
// Use retry wrapper to handle connection failures
const settings = await withRetry(async () => {
return await db('app_settings')
.where('setting_key', 'security_password_complexity_level')
.first();
});
if (!settings || !settings.setting_value) {
return 'moderate'; // Default
}
const value = typeof settings.setting_value === 'string'
? JSON.parse(settings.setting_value)
: settings.setting_value;
return value;
} catch (error) {
logger.error('Failed to get password complexity settings:', error);
return 'moderate'; // Default on error - ensures app continues working
}
}
/**
* Get password configuration based on complexity level
* @param {string} complexityLevel - Complexity level (simple, moderate, strong, very_strong)
* @returns {Object} - Password configuration
*/
function getPasswordConfigForComplexity(complexityLevel) {
const configs = {
simple: {
minLength: 6,
requireUppercase: false,
requireLowercase: false,
requireNumbers: false,
requireSpecialChars: false,
preventCommonPasswords: true,
minStrengthScore: 0
},
moderate: {
minLength: 8,
requireUppercase: true,
requireLowercase: true,
requireNumbers: true,
requireSpecialChars: false,
preventCommonPasswords: true,
minStrengthScore: 2
},
strong: {
minLength: 12,
requireUppercase: true,
requireLowercase: true,
requireNumbers: true,
requireSpecialChars: false,
preventCommonPasswords: true,
minStrengthScore: 3
},
very_strong: {
minLength: 12,
requireUppercase: true,
requireLowercase: true,
requireNumbers: true,
requireSpecialChars: true,
preventCommonPasswords: true,
minStrengthScore: 3
}
};
return configs[complexityLevel] || configs.moderate;
}
/**
* Validate password for specific contexts (admin, gallery)
* @param {string} password - Password to validate
@@ -120,19 +198,16 @@ function validatePassword(password, options = {}) {
* @param {Object} userData - Additional user data for context-aware validation
* @returns {Object} - Validation result
*/
function validatePasswordInContext(password, context, userData = {}) {
// For gallery context, use more lenient validation
async function validatePasswordInContext(password, context, userData = {}) {
// For gallery context, use dynamic complexity settings
if (context === 'gallery') {
// Gallery-specific validation options
// Get complexity settings from database
const complexityLevel = await getPasswordComplexitySettings();
// Get configuration for the complexity level
const galleryOptions = {
minLength: 6, // Reduced minimum length
requireUppercase: false, // Don't require uppercase for galleries
requireLowercase: false, // Don't require lowercase for galleries
requireNumbers: false, // Numbers are optional
requireSpecialChars: false, // Special chars are optional
preventCommonPasswords: true, // Still prevent common passwords
minStrengthScore: 0, // Accept any score for galleries
skipStrengthCheck: true // Skip zxcvbn strength analysis for galleries
...getPasswordConfigForComplexity(complexityLevel),
skipStrengthCheck: complexityLevel === 'simple' // Skip zxcvbn for simple passwords
};
// Base validation with gallery-specific options
@@ -281,5 +356,7 @@ module.exports = {
generateSecurePassword,
getBcryptRounds,
logPasswordValidationFailure,
getPasswordComplexitySettings,
getPasswordConfigForComplexity,
PASSWORD_CONFIG
};
+1 -1
View File
@@ -47,7 +47,7 @@ function escapeLikePattern(input) {
.replace(/\\/g, '\\\\') // Escape backslashes first
.replace(/%/g, '\\%') // Escape percent signs
.replace(/_/g, '\\_') // Escape underscores
.replace(/'/g, "''"); // Escape single quotes for safety
.replace(/'/g, '\'\''); // Escape single quotes for safety
}
/**
+478
View File
@@ -0,0 +1,478 @@
# Comprehensive Backup & Restore Implementation Plan
## Introduction
This plan extends the existing backup system to include full database backups, S3/MinIO support, intelligent change detection, and complete restore functionality. The system will create versioned, encrypted backups with manifests for easy restoration while minimizing storage usage through incremental backups and smart scheduling.
---
## Phase 1: Enhanced Database Schema & Core Infrastructure
### Task 1.1: Create Enhanced Database Migration
**File:** `backend/migrations/030_enhance_backup_system.js`
**Purpose:** Add tables for database backups, restore operations, and backup manifests
```sql
-- backup_manifests table
- id (primary key)
- backup_run_id (FK to backup_runs)
- manifest_version (e.g., "1.0.0")
- created_at
- database_dump_path
- database_checksum
- files_manifest (JSON with all file paths/checksums)
- metadata (JSON with system info, versions, etc.)
-- restore_operations table
- id (primary key)
- started_at
- completed_at
- status (pending, running, completed, failed)
- restore_type (full, partial, database_only, files_only)
- source_backup_id (FK to backup_runs)
- restored_by (FK to admin_users)
- error_message
- restore_log (detailed log)
-- backup_change_tracking table
- id (primary key)
- table_name
- last_change_timestamp
- row_count
- checksum
- last_backed_up
```
### Task 1.2: Add S3 Configuration Settings
**File:** Update migration `029_add_backup_service_tables.js`
**Add settings:**
- `backup_s3_use_ssl` (boolean)
- `backup_s3_path_style` (for MinIO compatibility)
- `backup_encryption_enabled` (boolean)
- `backup_encryption_key` (encrypted storage)
- `backup_database_included` (boolean)
- `backup_incremental_enabled` (boolean)
- `backup_versioning_enabled` (boolean)
- `backup_versions_to_keep` (number)
---
## Phase 2: S3/MinIO Implementation
### Task 2.1: Install S3 Dependencies
**File:** `backend/package.json`
**Command:** `npm install @aws-sdk/client-s3 @aws-sdk/lib-storage mime-types`
**Purpose:** AWS SDK v3 for S3-compatible storage
### Task 2.2: Create S3 Storage Adapter
**File:** `backend/src/services/storage/s3Storage.js`
**Implementation:**
```javascript
class S3StorageAdapter {
constructor(config)
connect() // Test connection
uploadFile(localPath, remotePath, metadata)
uploadStream(stream, remotePath, metadata)
downloadFile(remotePath, localPath)
listFiles(prefix)
deleteFile(remotePath)
getSignedUrl(remotePath, expiresIn)
createMultipartUpload(remotePath) // For large files
uploadPart(uploadId, partNumber, data)
completeMultipartUpload(uploadId, parts)
}
```
### Task 2.3: Implement MinIO Compatibility Layer
**File:** `backend/src/services/storage/minioCompat.js`
**Features:**
- Path-style URL handling
- Custom endpoint configuration
- SSL/TLS options
- Bucket creation if not exists
---
## Phase 3: Database Backup Integration
### Task 3.1: Create Database Dump Service
**File:** `backend/src/services/databaseBackup.js`
**Implementation:**
```javascript
class DatabaseBackupService {
async createBackup(format = 'sql') // sql or json
async dumpSQLite(outputPath)
async dumpPostgreSQL(outputPath)
async compressBackup(inputPath, outputPath)
async encryptBackup(inputPath, outputPath, key)
async validateBackup(backupPath)
async getTableChecksums() // For change detection
}
```
### Task 3.2: Implement Change Detection for Database
**File:** `backend/src/services/changeDetection.js`
**Features:**
- Track table modifications using triggers
- Calculate table checksums
- Compare with last backup state
- Intelligent backup decision making
---
## Phase 4: Enhanced Backup Service
### Task 4.1: Refactor Backup Service for S3
**File:** `backend/src/services/backupService.js`
**Modifications:**
- Add `performS3Backup()` implementation
- Support multipart uploads for large files
- Add progress tracking callbacks
- Implement retry logic with exponential backoff
### Task 4.2: Create Backup Manifest Generator
**File:** `backend/src/services/backupManifest.js`
**Structure:**
```javascript
{
version: "1.0.0",
created_at: "2024-01-20T10:00:00Z",
system_info: {
app_version: "1.0.74",
node_version: "18.x",
database_type: "sqlite|postgresql"
},
database: {
dump_file: "database/dump.sql.gz",
checksum: "sha256:...",
tables: { /* table info */ }
},
files: {
count: 1234,
total_size: 5678901234,
entries: [
{
path: "events/active/...",
checksum: "sha256:...",
size: 12345,
modified: "2024-01-20T09:00:00Z"
}
]
},
settings: { /* app settings snapshot */ }
}
```
### Task 4.3: Implement Incremental Backup Logic
**File:** `backend/src/services/incrementalBackup.js`
**Features:**
- Track changed files since last full backup
- Create incremental manifest
- Link to parent backup
- Merge incremental backups
---
## Phase 5: Restore Functionality
### Task 5.1: Create Restore Service
**File:** `backend/src/services/restoreService.js`
**Implementation:**
```javascript
class RestoreService {
async validateBackup(backupId)
async prepareRestore(backupId, options)
async restoreDatabase(manifestPath)
async restoreFiles(manifestPath, options)
async performFullRestore(backupId)
async performPartialRestore(backupId, selections)
async rollbackRestore(restoreId)
async verifyRestore(restoreId)
}
```
### Task 5.2: Implement Safe Restore Process
**File:** `backend/src/services/restoreValidation.js`
**Safety Features:**
- Pre-restore backup creation
- Validation checksums
- Atomic operations
- Rollback capability
- Post-restore verification
### Task 5.3: Create Restore CLI Tool
**File:** `backend/scripts/restore-backup.js`
**Purpose:** Emergency restore without running application
**Features:**
- Interactive mode
- Dry-run option
- Progress display
- Validation reports
---
## Phase 6: Admin API Extensions
### Task 6.1: Add Restore Endpoints
**File:** `backend/src/routes/adminBackup.js`
**New Endpoints:**
```javascript
POST /api/admin/backup/restore/validate
POST /api/admin/backup/restore/start
GET /api/admin/backup/restore/:id/status
POST /api/admin/backup/restore/:id/cancel
GET /api/admin/backup/manifests/:backupId
GET /api/admin/backup/download/:backupId
```
### Task 6.2: Add S3 Management Endpoints
**File:** `backend/src/routes/adminBackup.js`
**New Endpoints:**
```javascript
GET /api/admin/backup/s3/buckets
GET /api/admin/backup/s3/files
DELETE /api/admin/backup/s3/cleanup
POST /api/admin/backup/s3/test-upload
```
---
## Phase 7: Frontend Implementation
### Task 7.1: Create Backup Management Page
**File:** `frontend/src/pages/admin/BackupManagement.jsx`
**Components:**
- Backup configuration form
- Backup history table
- Manual backup trigger
- Restore interface
- Progress indicators
### Task 7.2: Create Backup Status Dashboard
**File:** `frontend/src/components/admin/BackupDashboard.jsx`
**Features:**
- Real-time backup status
- Storage usage charts
- Backup success rate
- Next scheduled backup
- Recent backup/restore operations
### Task 7.3: Implement Restore Wizard
**File:** `frontend/src/components/admin/RestoreWizard.jsx`
**Steps:**
1. Select backup to restore
2. Choose restore type (full/partial)
3. Select components (database/files/settings)
4. Review and confirm
5. Monitor progress
6. Verify results
---
## Phase 8: Background Job Enhancements
### Task 8.1: Implement Smart Scheduling
**File:** `backend/src/services/smartScheduler.js`
**Features:**
- Skip backup if no changes detected
- Adaptive scheduling based on activity
- Priority queuing for critical backups
- Resource usage monitoring
### Task 8.2: Create Backup Monitor Service
**File:** `backend/src/services/backupMonitor.js`
**Purpose:** Monitor backup health and alert on issues
**Features:**
- Check last successful backup age
- Verify backup integrity periodically
- Monitor storage usage
- Alert on failures or anomalies
---
## Phase 9: Security & Encryption
### Task 9.1: Implement Backup Encryption
**File:** `backend/src/utils/encryption.js`
**Features:**
- AES-256-GCM encryption
- Key derivation from master key
- Encrypted manifest headers
- Secure key storage
### Task 9.2: Add Access Control
**File:** `backend/src/middleware/backupAuth.js`
**Features:**
- Separate permissions for backup/restore
- Audit logging for all operations
- IP whitelist for restore operations
- Two-factor authentication for restore
---
## Phase 10: Testing & Validation
### Task 10.1: Create Backup Test Suite
**File:** `backend/__tests__/services/backup.test.js`
**Tests:**
- Unit tests for each backup method
- Integration tests with real S3/MinIO
- Database backup/restore cycles
- Encryption/decryption validation
- Manifest generation and parsing
### Task 10.2: Create Restore Test Suite
**File:** `backend/__tests__/services/restore.test.js`
**Tests:**
- Full restore scenarios
- Partial restore validation
- Rollback testing
- Corruption recovery
- Cross-version compatibility
### Task 10.3: Create E2E Backup/Restore Tests
**File:** `backend/__tests__/e2e/backupRestore.test.js`
**Scenarios:**
- Complete backup/restore cycle
- Disaster recovery simulation
- Performance benchmarks
- Storage optimization validation
---
## Phase 11: Documentation & Deployment
### Task 11.1: Create Backup Administrator Guide
**File:** `docs/backup-admin-guide.md`
**Contents:**
- Configuration guide
- Best practices
- Troubleshooting
- Recovery procedures
- Performance tuning
### Task 11.2: Update Docker Configuration
**Files:** `docker-compose.yml`, `Dockerfile`
**Changes:**
- Add S3/MinIO service for development
- Volume mappings for backups
- Environment variable templates
- Health checks for backup service
### Task 11.3: Create Backup Playbook
**File:** `docs/backup-playbook.md`
**Scenarios:**
- Daily backup verification
- Disaster recovery steps
- Migration procedures
- Troubleshooting flowchart
---
## Implementation Order & Priority
### Critical Path (Must Have):
1. S3 Storage Adapter (Task 2.2)
2. Database Backup Service (Task 3.1)
3. Enhanced Backup Service (Task 4.1)
4. Basic Restore Service (Task 5.1)
5. Admin API Extensions (Task 6.1)
6. Backup Test Suite (Task 10.1)
### High Priority (Should Have):
1. Backup Manifest Generator (Task 4.2)
2. Change Detection (Task 3.2)
3. Frontend Backup Page (Task 7.1)
4. Encryption Implementation (Task 9.1)
5. Restore Wizard (Task 7.3)
### Nice to Have:
1. Incremental Backups (Task 4.3)
2. Smart Scheduling (Task 8.1)
3. Advanced Monitoring (Task 8.2)
4. MinIO Compatibility (Task 2.3)
---
## Configuration Examples
### S3 Configuration:
```javascript
{
backup_destination_type: "s3",
backup_s3_endpoint: "https://s3.amazonaws.com",
backup_s3_bucket: "wedding-backups",
backup_s3_access_key: "AKIA...",
backup_s3_secret_key: "secret",
backup_s3_region: "us-east-1",
backup_s3_use_ssl: true,
backup_s3_path_style: false
}
```
### MinIO Configuration:
```javascript
{
backup_destination_type: "s3",
backup_s3_endpoint: "https://minio.example.com:9000",
backup_s3_bucket: "picpeak-backups",
backup_s3_access_key: "minioadmin",
backup_s3_secret_key: "minioadmin",
backup_s3_region: "us-east-1",
backup_s3_use_ssl: true,
backup_s3_path_style: true // Required for MinIO
}
```
---
## Best Practices Implementation
1. **Change Detection**: Use database triggers and file checksums to detect changes
2. **Compression**: Always compress before encryption for better ratios
3. **Chunking**: Split large backups into manageable chunks
4. **Versioning**: Keep multiple backup versions with rotation
5. **Validation**: Verify every backup immediately after creation
6. **Monitoring**: Alert on backup failures within 5 minutes
7. **Testing**: Perform monthly restore drills
8. **Documentation**: Log every backup/restore operation with details
---
## Key Features of This Implementation
### Intelligent Change Detection
- Only backs up when changes are detected
- Tracks database modifications via checksums
- Monitors file system changes
- Reduces unnecessary backup operations
### Comprehensive Backup Scope
- Full database dumps (SQLite/PostgreSQL)
- All event photos and thumbnails
- Application settings and configuration
- Email templates and user data
- Complete system state capture
### Flexible Storage Options
- Local directory backup
- Remote server via rsync
- S3-compatible storage (AWS, MinIO, etc.)
- Encrypted storage for security
- Compression for space efficiency
### Robust Restore Capabilities
- Full system restore
- Partial restore (specific events/data)
- Point-in-time recovery
- Pre-restore validation
- Rollback on failure
### Enterprise-Grade Features
- Backup manifests for verification
- Incremental backup support
- Version retention policies
- Automated cleanup of old backups
- Comprehensive audit logging
This comprehensive plan provides a robust, enterprise-grade backup solution with full disaster recovery capabilities.
+13 -8
View File
@@ -1,11 +1,16 @@
# Backend API URL
VITE_API_URL=http://localhost:3000
# For local development:
VITE_API_URL=http://localhost:3001
# Umami Analytics Configuration
# Get these values from your Umami installation
VITE_UMAMI_URL=https://analytics.yourdomain.com
VITE_UMAMI_WEBSITE_ID=your-website-id-from-umami
# For production behind reverse proxy (Traefik, nginx, etc):
# VITE_API_URL=/api
# Optional: Umami share URL for embedding full dashboard
# This is the public share URL from Umami's share feature
VITE_UMAMI_SHARE_URL=https://analytics.yourdomain.com/share/your-share-id/photo-sharing
# Umami Analytics Configuration (OPTIONAL - Fallback only)
# NOTE: Primary Umami configuration should be done through Admin UI > Settings > Analytics
# These environment variables serve as fallbacks when backend settings are not available
# Useful for: development environments, initial setup, or when backend is unavailable
#
# Example values:
# VITE_UMAMI_URL=https://analytics.example.com
# VITE_UMAMI_WEBSITE_ID=abc123def-4567-89ab-cdef-0123456789ab
# VITE_UMAMI_SHARE_URL=https://analytics.example.com/share/xyz789/wedding-photos
+8 -4
View File
@@ -8,7 +8,11 @@ VITE_API_URL=/api
# For development or if frontend/backend are on different domains:
# VITE_API_URL=https://api.yourdomain.com
# Umami Analytics Configuration (optional)
# VITE_UMAMI_URL=https://analytics.yourdomain.com
# VITE_UMAMI_WEBSITE_ID=your-website-id-from-umami
# VITE_UMAMI_SHARE_URL=https://analytics.yourdomain.com/share/your-share-id/photo-sharing
# Umami Analytics Configuration (OPTIONAL - Fallback only)
# NOTE: Primary Umami configuration should be done through Admin UI > Settings > Analytics
# These environment variables serve as fallbacks when backend settings are not available
#
# Real-world example values:
# VITE_UMAMI_URL=https://analytics.picpeak.com
# VITE_UMAMI_WEBSITE_ID=b4d3c2a1-5678-90ab-cdef-1234567890ab
# VITE_UMAMI_SHARE_URL=https://analytics.picpeak.com/share/Ab3Cd5Fg/picpeak-gallery
+153 -5
View File
@@ -1,18 +1,24 @@
{
"name": "picpeak-frontend",
"version": "1.0.53",
"version": "1.0.76",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.0.53",
"version": "1.0.76",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
"@tiptap/extension-code-block-lowlight": "^2.26.1",
"@tiptap/extension-hard-break": "^2.26.1",
"@tiptap/extension-link": "^2.25.0",
"@tiptap/extension-placeholder": "^2.26.1",
"@tiptap/extension-text-align": "^2.26.1",
"@tiptap/react": "^2.25.0",
"@tiptap/starter-kit": "^2.25.0",
"@types/dompurify": "^3.0.5",
"@types/lodash": "^4.17.20",
"@types/react-google-recaptcha": "^2.1.9",
"axios": "^1.3.2",
"clsx": "^2.0.0",
@@ -22,6 +28,8 @@
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lowlight": "^2.9.0",
"lucide-react": "^0.292.0",
"react": "^18.3.1",
"react-countdown": "^2.3.5",
@@ -1541,6 +1549,20 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-character-count": {
"version": "2.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-character-count/-/extension-character-count-2.26.1.tgz",
"integrity": "sha512-F7LP1a9GF28thbApowWT2I41baqX74HMUTrV9LGrNXaOkW2gxZz+CDOzfHsbHyfuwfIxIjv07Qf/HKA6Cc1qbA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.25.0.tgz",
@@ -1568,6 +1590,23 @@
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-code-block-lowlight": {
"version": "2.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block-lowlight/-/extension-code-block-lowlight-2.26.1.tgz",
"integrity": "sha512-yptuTPYAzVMKHUTwNKYveuu0rYHYyFknPz3O2++PWeeBGxkNB+T6LhwZ/JhXceHcZxzlGyka9r2mXR7pslhugw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/extension-code-block": "^2.7.0",
"@tiptap/pm": "^2.7.0",
"highlight.js": "^11",
"lowlight": "^2 || ^3"
}
},
"node_modules/@tiptap/extension-document": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.25.0.tgz",
@@ -1627,9 +1666,9 @@
}
},
"node_modules/@tiptap/extension-hard-break": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.25.0.tgz",
"integrity": "sha512-h8be5Zdtsl5GQHxRXvYlGfIJsLvdbexflSTr12gr4kvcQqTdtrsqyu2eksfAK+p2szbiwP2G4VZlH0LNS47UXQ==",
"version": "2.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-2.26.1.tgz",
"integrity": "sha512-d6uStdNKi8kjPlHAyO59M6KGWATNwhLCD7dng0NXfwGndc22fthzIk/6j9F6ltQx30huy5qQram6j3JXwNACoA==",
"license": "MIT",
"funding": {
"type": "github",
@@ -1749,6 +1788,20 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-placeholder": {
"version": "2.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-2.26.1.tgz",
"integrity": "sha512-MBlqbkd+63btY7Qu+SqrXvWjPwooGZDsLTtl7jp52BczBl61cq9yygglt9XpM11TFMBdySgdLHBrLtQ0B7fBlw==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0",
"@tiptap/pm": "^2.7.0"
}
},
"node_modules/@tiptap/extension-strike": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.25.0.tgz",
@@ -1775,6 +1828,19 @@
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-align": {
"version": "2.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-2.26.1.tgz",
"integrity": "sha512-x6mpNGELy2QtSPBoQqNgiXO9PjZoB+O2EAfXA9YRiBDSIRNOrw+7vOVpi+IgzswFmhMNgIYUVfQRud4FHUCNew==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/ueberdosis"
},
"peerDependencies": {
"@tiptap/core": "^2.7.0"
}
},
"node_modules/@tiptap/extension-text-style": {
"version": "2.25.0",
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.25.0.tgz",
@@ -1935,6 +2001,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/hast": {
"version": "2.3.10",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz",
"integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^2"
}
},
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
@@ -1955,6 +2030,12 @@
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==",
"license": "MIT"
},
"node_modules/@types/lodash": {
"version": "4.17.20",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.20.tgz",
"integrity": "sha512-H3MHACvFUEiujabxhaI/ImO6gUrd8oOurg7LQtS7mbwIXA/cUqWrvBsaeJ23aZEPk1TAYkurjfMbSELfoCXlGA==",
"license": "MIT"
},
"node_modules/@types/markdown-it": {
"version": "14.1.2",
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
@@ -2012,6 +2093,12 @@
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
"integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
"license": "MIT"
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
@@ -3233,6 +3320,19 @@
"reusify": "^1.0.4"
}
},
"node_modules/fault": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/fault/-/fault-2.0.1.tgz",
"integrity": "sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==",
"license": "MIT",
"dependencies": {
"format": "^0.2.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -3350,6 +3450,14 @@
"node": ">= 6"
}
},
"node_modules/format": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
"engines": {
"node": ">=0.4.x"
}
},
"node_modules/fraction.js": {
"version": "4.3.7",
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz",
@@ -3576,6 +3684,16 @@
"node": ">= 0.4"
}
},
"node_modules/highlight.js": {
"version": "11.11.1",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz",
"integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==",
"license": "BSD-3-Clause",
"peer": true,
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/hoist-non-react-statics": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
@@ -3935,6 +4053,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -3954,6 +4078,30 @@
"loose-envify": "cli.js"
}
},
"node_modules/lowlight": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-2.9.0.tgz",
"integrity": "sha512-OpcaUTCLmHuVuBcyNckKfH5B0oA4JUavb/M/8n9iAvanJYNQkrVm4pvyX0SUaqkBG4dnWHKt7p50B3ngAG2Rfw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^2.0.0",
"fault": "^2.0.0",
"highlight.js": "~11.8.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/lowlight/node_modules/highlight.js": {
"version": "11.8.0",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.8.0.tgz",
"integrity": "sha512-MedQhoqVdr0U6SSnWPzfiadUcDHfN/Wzq25AkXiQv9oiOO/sG0S7XkvpFIqWBl9Yq1UYyYOOVORs5UW2XlPyzg==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+9 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.0.53",
"version": "1.0.76",
"type": "module",
"scripts": {
"dev": "vite",
@@ -12,10 +12,16 @@
},
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
"@tiptap/extension-code-block-lowlight": "^2.26.1",
"@tiptap/extension-hard-break": "^2.26.1",
"@tiptap/extension-link": "^2.25.0",
"@tiptap/extension-placeholder": "^2.26.1",
"@tiptap/extension-text-align": "^2.26.1",
"@tiptap/react": "^2.25.0",
"@tiptap/starter-kit": "^2.25.0",
"@types/dompurify": "^3.0.5",
"@types/lodash": "^4.17.20",
"@types/react-google-recaptcha": "^2.1.9",
"axios": "^1.3.2",
"clsx": "^2.0.0",
@@ -25,6 +31,8 @@
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"js-cookie": "^3.0.5",
"lodash": "^4.17.21",
"lowlight": "^2.9.0",
"lucide-react": "^0.292.0",
"react": "^18.3.1",
"react-countdown": "^2.3.5",
+32 -15
View File
@@ -21,8 +21,10 @@ import {
AnalyticsPage,
BrandingPage,
SettingsPage,
CMSPage
CMSPage,
BackupManagement
} from './pages/admin';
import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced';
import { AdminLayout, AdminAuthWrapper } from './components/admin';
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
@@ -43,17 +45,26 @@ function App() {
// Initialize Umami Analytics based on settings
useEffect(() => {
const initializeAnalytics = async () => {
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
if (umamiUrl && umamiWebsiteId) {
try {
// Fetch public settings to check if analytics is enabled
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
const settings = await response.json();
try {
// Fetch public settings to get Umami configuration
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
const settings = await response.json();
// Check if Umami is enabled and configured in backend settings
if (settings.umami_enabled && settings.umami_url && settings.umami_website_id) {
// Use backend configuration
analyticsService.initialize({
websiteId: settings.umami_website_id,
hostUrl: settings.umami_url,
autoTrack: true,
doNotTrack: true
});
} else {
// Fall back to environment variables if backend not configured
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
// Only initialize if analytics is enabled in settings
if (settings.enable_analytics !== false) {
if (umamiUrl && umamiWebsiteId && settings.enable_analytics !== false) {
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
@@ -61,9 +72,14 @@ function App() {
doNotTrack: true
});
}
} catch (error) {
console.error('Failed to fetch settings for analytics:', error);
// Initialize analytics anyway if settings fetch fails
}
} catch (error) {
console.error('Failed to fetch settings for analytics:', error);
// Fall back to environment variables on error
const umamiUrl = import.meta.env.VITE_UMAMI_URL;
const umamiWebsiteId = import.meta.env.VITE_UMAMI_WEBSITE_ID;
if (umamiUrl && umamiWebsiteId) {
analyticsService.initialize({
websiteId: umamiWebsiteId,
hostUrl: umamiUrl,
@@ -109,7 +125,8 @@ function App() {
<Route path="analytics" element={<AnalyticsPage />} />
<Route path="branding" element={<BrandingPage />} />
<Route path="settings" element={<SettingsPage />} />
<Route path="cms" element={<CMSPage />} />
<Route path="backup" element={<BackupManagement />} />
<Route path="cms" element={<CMSPageEnhanced />} />
<Route index element={<Navigate to="/admin/dashboard" replace />} />
</Route>
</Route>
@@ -36,23 +36,7 @@ export const AdminAuthenticatedImage: React.FC<AdminAuthenticatedImageProps> = (
setLoading(false);
}
} catch (err: any) {
console.error('Failed to load image:', src, err);
// Log more details about the error
if (err.response) {
console.error('Response status:', err.response.status);
console.error('Response headers:', err.response.headers);
if (err.response.data instanceof Blob) {
// Try to read error message from blob
try {
const text = await err.response.data.text();
console.error('Response data:', text);
} catch (e) {
console.error('Could not read blob data');
}
} else {
console.error('Response data:', err.response.data);
}
}
// Image loading failed - handled by error state
if (!cancelled) {
setError(true);
setLoading(false);
@@ -9,7 +9,8 @@ import {
Settings,
X,
Palette,
FileText
FileText,
HardDrive
} from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
@@ -35,6 +36,7 @@ const navigation: NavItem[] = [
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail },
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette },
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings },
{ nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive },
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText },
];
@@ -0,0 +1,608 @@
import React, { useState, useEffect } from 'react';
import {
Save,
Server,
Cloud,
HardDrive,
Clock,
Calendar,
Shield,
AlertCircle,
Info,
Eye,
EyeOff,
TestTube,
CheckCircle,
XCircle,
Loader2,
FolderOpen,
Database,
Image,
FileArchive
} from 'lucide-react';
import { toast } from 'react-toastify';
import { Button, Card, Input } from '../common';
const destinationTypes = [
{
id: 'local',
name: 'Local Storage',
icon: HardDrive,
description: 'Store backups on the local server filesystem',
fields: ['backup_destination_path']
},
{
id: 'rsync',
name: 'Remote Server (Rsync)',
icon: Server,
description: 'Sync backups to a remote server via SSH/Rsync',
fields: ['backup_rsync_host', 'backup_rsync_user', 'backup_rsync_path', 'backup_rsync_ssh_key']
},
{
id: 's3',
name: 'S3 Compatible Storage',
icon: Cloud,
description: 'Store backups in Amazon S3 or compatible object storage',
fields: ['backup_s3_endpoint', 'backup_s3_bucket', 'backup_s3_access_key', 'backup_s3_secret_key', 'backup_s3_region']
}
];
const scheduleOptions = [
{ value: 'hourly', label: 'Every hour' },
{ value: 'daily', label: 'Daily' },
{ value: 'weekly', label: 'Weekly' },
{ value: 'custom', label: 'Custom cron expression' }
];
export const BackupConfiguration = ({ config, onSave, isSaving }) => {
const [formData, setFormData] = useState({
backup_enabled: false,
backup_destination_type: 'local',
backup_destination_path: '',
backup_rsync_host: '',
backup_rsync_user: '',
backup_rsync_path: '',
backup_rsync_ssh_key: '',
backup_s3_endpoint: '',
backup_s3_bucket: '',
backup_s3_access_key: '',
backup_s3_secret_key: '',
backup_s3_region: '',
backup_schedule: 'daily',
backup_schedule_cron: '0 3 * * *',
backup_retention_days: 30,
backup_include_database: true,
backup_include_photos: true,
backup_include_archives: true,
backup_include_thumbnails: false,
backup_include_temp: false,
backup_compression: true,
backup_encryption: false,
backup_encryption_passphrase: ''
});
const [showSecrets, setShowSecrets] = useState({
s3_secret_key: false,
ssh_key: false,
encryption_passphrase: false
});
const [testingConnection, setTestingConnection] = useState(false);
useEffect(() => {
if (config) {
setFormData(prev => ({
...prev,
...config
}));
}
}, [config]);
const handleChange = (field, value) => {
setFormData(prev => ({
...prev,
[field]: value
}));
};
const handleSubmit = (e) => {
e.preventDefault();
// Validate required fields
const destinationType = destinationTypes.find(t => t.id === formData.backup_destination_type);
const missingFields = [];
if (formData.backup_enabled && destinationType) {
destinationType.fields.forEach(field => {
if (!formData[field] && !field.includes('optional')) {
missingFields.push(field);
}
});
}
if (missingFields.length > 0) {
toast.error('Please fill in all required fields');
return;
}
onSave(formData);
};
const testConnection = async () => {
setTestingConnection(true);
try {
// TODO: Implement connection test endpoint
await new Promise(resolve => setTimeout(resolve, 2000));
toast.success('Connection test successful!');
} catch (error) {
toast.error('Connection test failed: ' + error.message);
} finally {
setTestingConnection(false);
}
};
const selectedDestination = destinationTypes.find(t => t.id === formData.backup_destination_type);
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Enable/Disable Toggle */}
<Card className="p-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<h3 className="text-lg font-semibold text-gray-900">Backup Service</h3>
<p className="mt-1 text-sm text-gray-600">
Enable automatic backups to protect your data
</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input
type="checkbox"
checked={formData.backup_enabled}
onChange={(e) => handleChange('backup_enabled', e.target.checked)}
className="sr-only peer"
/>
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-primary-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-primary"></div>
</label>
</div>
</Card>
{/* Destination Configuration */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Backup Destination</h3>
{/* Destination Type Selection */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
{destinationTypes.map((type) => {
const Icon = type.icon;
return (
<button
key={type.id}
type="button"
onClick={() => handleChange('backup_destination_type', type.id)}
className={`p-4 rounded-lg border-2 transition-all ${
formData.backup_destination_type === type.id
? 'border-primary bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Icon className={`h-8 w-8 mb-2 mx-auto ${
formData.backup_destination_type === type.id
? 'text-primary'
: 'text-gray-400'
}`} />
<h4 className="font-medium text-gray-900">{type.name}</h4>
<p className="text-xs text-gray-500 mt-1">{type.description}</p>
</button>
);
})}
</div>
{/* Destination-specific fields */}
<div className="space-y-4">
{formData.backup_destination_type === 'local' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Backup Directory Path
</label>
<Input
type="text"
value={formData.backup_destination_path}
onChange={(e) => handleChange('backup_destination_path', e.target.value)}
placeholder="/path/to/backup/directory"
required
/>
<p className="mt-1 text-xs text-gray-500">
Absolute path where backups will be stored
</p>
</div>
</>
)}
{formData.backup_destination_type === 'rsync' && (
<>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
SSH Host
</label>
<Input
type="text"
value={formData.backup_rsync_host}
onChange={(e) => handleChange('backup_rsync_host', e.target.value)}
placeholder="backup.example.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
SSH User
</label>
<Input
type="text"
value={formData.backup_rsync_user}
onChange={(e) => handleChange('backup_rsync_user', e.target.value)}
placeholder="backup-user"
required
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Remote Path
</label>
<Input
type="text"
value={formData.backup_rsync_path}
onChange={(e) => handleChange('backup_rsync_path', e.target.value)}
placeholder="/home/backup/photo-sharing"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
SSH Private Key (optional)
</label>
<div className="relative">
<textarea
value={formData.backup_rsync_ssh_key}
onChange={(e) => handleChange('backup_rsync_ssh_key', e.target.value)}
placeholder="-----BEGIN RSA PRIVATE KEY-----"
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary focus:border-primary font-mono text-sm"
rows={4}
/>
<button
type="button"
onClick={() => setShowSecrets(prev => ({ ...prev, ssh_key: !prev.ssh_key }))}
className="absolute top-2 right-2 text-gray-400 hover:text-gray-600"
>
{showSecrets.ssh_key ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
<p className="mt-1 text-xs text-gray-500">
Leave empty to use system SSH keys
</p>
</div>
</>
)}
{formData.backup_destination_type === 's3' && (
<>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
S3 Endpoint URL
</label>
<Input
type="text"
value={formData.backup_s3_endpoint}
onChange={(e) => handleChange('backup_s3_endpoint', e.target.value)}
placeholder="https://s3.amazonaws.com"
required
/>
<p className="mt-1 text-xs text-gray-500">
Use default for AWS S3, or your provider's endpoint
</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Bucket Name
</label>
<Input
type="text"
value={formData.backup_s3_bucket}
onChange={(e) => handleChange('backup_s3_bucket', e.target.value)}
placeholder="my-backup-bucket"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Region
</label>
<Input
type="text"
value={formData.backup_s3_region}
onChange={(e) => handleChange('backup_s3_region', e.target.value)}
placeholder="us-east-1"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Access Key ID
</label>
<Input
type="text"
value={formData.backup_s3_access_key}
onChange={(e) => handleChange('backup_s3_access_key', e.target.value)}
placeholder="AKIAIOSFODNN7EXAMPLE"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Secret Access Key
</label>
<div className="relative">
<Input
type={showSecrets.s3_secret_key ? 'text' : 'password'}
value={formData.backup_s3_secret_key}
onChange={(e) => handleChange('backup_s3_secret_key', e.target.value)}
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
required
/>
<button
type="button"
onClick={() => setShowSecrets(prev => ({ ...prev, s3_secret_key: !prev.s3_secret_key }))}
className="absolute top-1/2 -translate-y-1/2 right-2 text-gray-400 hover:text-gray-600"
>
{showSecrets.s3_secret_key ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
</div>
</div>
</>
)}
{/* Test Connection Button */}
{formData.backup_destination_type && (
<div className="pt-2">
<Button
type="button"
onClick={testConnection}
disabled={testingConnection}
variant="secondary"
size="sm"
>
{testingConnection ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
</>
) : (
<>
<TestTube className="mr-2 h-4 w-4" />
Test Connection
</>
)}
</Button>
</div>
)}
</div>
</Card>
{/* Schedule Configuration */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Backup Schedule</h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Schedule
</label>
<select
value={formData.backup_schedule}
onChange={(e) => handleChange('backup_schedule', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary focus:border-primary"
>
{scheduleOptions.map(option => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
{formData.backup_schedule === 'custom' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Cron Expression
</label>
<Input
type="text"
value={formData.backup_schedule_cron}
onChange={(e) => handleChange('backup_schedule_cron', e.target.value)}
placeholder="0 3 * * *"
/>
<p className="mt-1 text-xs text-gray-500">
Use standard cron syntax (minute hour day month weekday)
</p>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Retention Period (days)
</label>
<Input
type="number"
value={formData.backup_retention_days}
onChange={(e) => handleChange('backup_retention_days', parseInt(e.target.value))}
min="1"
max="365"
/>
<p className="mt-1 text-xs text-gray-500">
Backups older than this will be automatically deleted
</p>
</div>
</div>
</Card>
{/* Backup Content Selection */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Backup Content</h3>
<div className="space-y-3">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.backup_include_database}
onChange={(e) => handleChange('backup_include_database', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
/>
<div className="ml-3">
<div className="flex items-center space-x-2">
<Database className="h-4 w-4 text-gray-400" />
<span className="text-sm font-medium text-gray-700">Database</span>
</div>
<p className="text-xs text-gray-500">All application data and settings</p>
</div>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={formData.backup_include_photos}
onChange={(e) => handleChange('backup_include_photos', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
/>
<div className="ml-3">
<div className="flex items-center space-x-2">
<Image className="h-4 w-4 text-gray-400" />
<span className="text-sm font-medium text-gray-700">Photos</span>
</div>
<p className="text-xs text-gray-500">All uploaded photos and galleries</p>
</div>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={formData.backup_include_archives}
onChange={(e) => handleChange('backup_include_archives', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
/>
<div className="ml-3">
<div className="flex items-center space-x-2">
<FileArchive className="h-4 w-4 text-gray-400" />
<span className="text-sm font-medium text-gray-700">Archives</span>
</div>
<p className="text-xs text-gray-500">Expired gallery archives</p>
</div>
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={formData.backup_include_thumbnails}
onChange={(e) => handleChange('backup_include_thumbnails', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
/>
<div className="ml-3">
<div className="flex items-center space-x-2">
<Image className="h-4 w-4 text-gray-400" />
<span className="text-sm font-medium text-gray-700">Thumbnails</span>
</div>
<p className="text-xs text-gray-500">Generated thumbnail images (can be regenerated)</p>
</div>
</label>
</div>
</Card>
{/* Advanced Options */}
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Advanced Options</h3>
<div className="space-y-4">
<label className="flex items-center">
<input
type="checkbox"
checked={formData.backup_compression}
onChange={(e) => handleChange('backup_compression', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
/>
<div className="ml-3">
<span className="text-sm font-medium text-gray-700">Enable Compression</span>
<p className="text-xs text-gray-500">Reduce backup size with gzip compression</p>
</div>
</label>
<div>
<label className="flex items-center mb-3">
<input
type="checkbox"
checked={formData.backup_encryption}
onChange={(e) => handleChange('backup_encryption', e.target.checked)}
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
/>
<div className="ml-3">
<span className="text-sm font-medium text-gray-700">Enable Encryption</span>
<p className="text-xs text-gray-500">Encrypt backups with AES-256</p>
</div>
</label>
{formData.backup_encryption && (
<div className="ml-7">
<label className="block text-sm font-medium text-gray-700 mb-1">
Encryption Passphrase
</label>
<div className="relative">
<Input
type={showSecrets.encryption_passphrase ? 'text' : 'password'}
value={formData.backup_encryption_passphrase}
onChange={(e) => handleChange('backup_encryption_passphrase', e.target.value)}
placeholder="Enter a strong passphrase"
required={formData.backup_encryption}
/>
<button
type="button"
onClick={() => setShowSecrets(prev => ({ ...prev, encryption_passphrase: !prev.encryption_passphrase }))}
className="absolute top-1/2 -translate-y-1/2 right-2 text-gray-400 hover:text-gray-600"
>
{showSecrets.encryption_passphrase ? <EyeOff size={20} /> : <Eye size={20} />}
</button>
</div>
<p className="mt-1 text-xs text-red-600">
<AlertCircle className="inline h-3 w-3 mr-1" />
Store this passphrase securely! You'll need it to restore encrypted backups.
</p>
</div>
)}
</div>
</div>
</Card>
{/* Save Button */}
<div className="flex justify-end">
<Button
type="submit"
disabled={isSaving}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Save Configuration
</>
)}
</Button>
</div>
</form>
);
};
@@ -0,0 +1,325 @@
import React from 'react';
import {
HardDrive,
Database,
FileArchive,
Image,
Clock,
CheckCircle,
AlertCircle,
TrendingUp,
Shield,
Server,
Cloud,
Calendar,
Play,
Loader2,
AlertTriangle,
Info
} from 'lucide-react';
import { format, formatDistanceToNow } from 'date-fns';
import { Card, Button } from '../common';
const StatCard = ({ icon: Icon, label, value, color = 'blue', subtext }) => (
<Card className="p-6">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="text-sm font-medium text-gray-600">{label}</p>
<p className="mt-2 text-3xl font-semibold text-gray-900">{value}</p>
{subtext && (
<p className="mt-1 text-sm text-gray-500">{subtext}</p>
)}
</div>
<div className={`p-3 bg-${color}-100 rounded-lg`}>
<Icon className={`h-6 w-6 text-${color}-600`} />
</div>
</div>
</Card>
);
const formatBytes = (bytes) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
export const BackupDashboard = ({ status, config, onRunBackup, isBackupRunning }) => {
const lastBackup = status?.lastBackup;
const statistics = lastBackup?.statistics || {};
const isConfigured = config && config.backup_destination_type;
const isEnabled = config?.backup_enabled;
// Calculate backup health score
const getHealthScore = () => {
if (!lastBackup) return { score: 0, status: 'critical', message: 'No backups found' };
const hoursSinceBackup = (Date.now() - new Date(lastBackup.created_at)) / (1000 * 60 * 60);
if (lastBackup.status === 'failed') {
return { score: 0, status: 'critical', message: 'Last backup failed' };
}
if (hoursSinceBackup < 24) {
return { score: 100, status: 'excellent', message: 'Backup is up to date' };
} else if (hoursSinceBackup < 48) {
return { score: 75, status: 'good', message: 'Backup is recent' };
} else if (hoursSinceBackup < 168) { // 1 week
return { score: 50, status: 'warning', message: 'Backup is getting old' };
} else {
return { score: 25, status: 'critical', message: 'Backup is outdated' };
}
};
const health = getHealthScore();
const healthColors = {
excellent: 'green',
good: 'blue',
warning: 'amber',
critical: 'red'
};
return (
<div className="space-y-6">
{/* Configuration Alert */}
{!isConfigured && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-amber-400 mt-0.5" />
<div className="ml-3">
<h3 className="text-sm font-medium text-amber-800">
Backup Not Configured
</h3>
<p className="mt-1 text-sm text-amber-700">
Please configure backup settings in the Configuration tab before running backups.
</p>
</div>
</div>
</div>
)}
{/* Health Score Card */}
<Card className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-gray-900">Backup Health</h3>
<span className={`px-3 py-1 rounded-full text-sm font-medium bg-${healthColors[health.status]}-100 text-${healthColors[health.status]}-700`}>
{health.status.charAt(0).toUpperCase() + health.status.slice(1)}
</span>
</div>
<div className="flex items-center space-x-4">
<div className="relative w-24 h-24">
<svg className="w-24 h-24 transform -rotate-90">
<circle
cx="48"
cy="48"
r="36"
stroke="currentColor"
strokeWidth="8"
fill="none"
className="text-gray-200"
/>
<circle
cx="48"
cy="48"
r="36"
stroke="currentColor"
strokeWidth="8"
fill="none"
strokeDasharray={`${(health.score / 100) * 226} 226`}
className={`text-${healthColors[health.status]}-500`}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-2xl font-bold text-gray-900">{health.score}%</span>
</div>
</div>
<div className="flex-1">
<p className="text-gray-700 font-medium">{health.message}</p>
{lastBackup && (
<p className="text-sm text-gray-500 mt-1">
Last successful backup: {formatDistanceToNow(new Date(lastBackup.created_at), { addSuffix: true })}
</p>
)}
<Button
onClick={onRunBackup}
disabled={!isConfigured || !isEnabled || isBackupRunning}
className="mt-3"
size="sm"
>
{isBackupRunning ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Running...
</>
) : (
<>
<Play className="mr-2 h-4 w-4" />
Run Backup Now
</>
)}
</Button>
</div>
</div>
</Card>
{/* Statistics Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
icon={FileArchive}
label="Total Backups"
value={status?.totalBackups || 0}
color="blue"
subtext={lastBackup ? `Last: ${format(new Date(lastBackup.created_at), 'PP')}` : 'No backups yet'}
/>
<StatCard
icon={HardDrive}
label="Backup Size"
value={formatBytes(statistics.total_size || 0)}
color="green"
subtext={`${statistics.files_processed || 0} files`}
/>
<StatCard
icon={Clock}
label="Last Duration"
value={lastBackup ? `${Math.round(lastBackup.duration_seconds / 60)}m` : 'N/A'}
color="purple"
subtext={lastBackup ? format(new Date(lastBackup.created_at), 'p') : ''}
/>
<StatCard
icon={Shield}
label="Backup Status"
value={isEnabled ? 'Active' : 'Inactive'}
color={isEnabled ? 'green' : 'gray'}
subtext={config?.backup_destination_type || 'Not configured'}
/>
</div>
{/* Recent Activity */}
{status?.recentBackups && status.recentBackups.length > 0 && (
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Recent Backup Activity</h3>
<div className="space-y-3">
{status.recentBackups.slice(0, 5).map((backup) => (
<div key={backup.id} className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0">
<div className="flex items-center space-x-3">
{backup.status === 'completed' ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : backup.status === 'failed' ? (
<AlertCircle className="h-5 w-5 text-red-500" />
) : (
<Loader2 className="h-5 w-5 text-blue-500 animate-spin" />
)}
<div>
<p className="font-medium text-gray-900">
{backup.backup_type} backup
</p>
<p className="text-sm text-gray-500">
{format(new Date(backup.created_at), 'PPp')}
</p>
</div>
</div>
<div className="text-right">
<p className="text-sm font-medium text-gray-900">
{formatBytes(backup.statistics?.total_size || 0)}
</p>
<p className="text-sm text-gray-500">
{backup.statistics?.files_processed || 0} files
</p>
</div>
</div>
))}
</div>
</Card>
)}
{/* Storage Status */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Backup Coverage</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Database className="h-5 w-5 text-gray-400" />
<span className="text-gray-700">Database</span>
</div>
<span className={`px-2 py-1 rounded text-xs font-medium ${
statistics.database_backed_up ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'
}`}>
{statistics.database_backed_up ? 'Backed up' : 'Not backed up'}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<Image className="h-5 w-5 text-gray-400" />
<span className="text-gray-700">Photos</span>
</div>
<span className="text-sm text-gray-500">
{statistics.photos_backed_up || 0} of {statistics.total_photos || 0}
</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<FileArchive className="h-5 w-5 text-gray-400" />
<span className="text-gray-700">Archives</span>
</div>
<span className="text-sm text-gray-500">
{statistics.archives_backed_up || 0} files
</span>
</div>
</div>
</Card>
<Card className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Destination Info</h3>
<div className="space-y-3">
<div className="flex items-center space-x-3">
{config?.backup_destination_type === 's3' ? (
<Cloud className="h-5 w-5 text-blue-500" />
) : config?.backup_destination_type === 'rsync' ? (
<Server className="h-5 w-5 text-purple-500" />
) : (
<HardDrive className="h-5 w-5 text-gray-500" />
)}
<div>
<p className="font-medium text-gray-900">
{config?.backup_destination_type
? config.backup_destination_type.toUpperCase()
: 'Not Configured'}
</p>
<p className="text-sm text-gray-500">
{config?.backup_destination_type === 's3' && config?.backup_s3_bucket
? `Bucket: ${config.backup_s3_bucket}`
: config?.backup_destination_type === 'local' && config?.backup_destination_path
? `Path: ${config.backup_destination_path}`
: config?.backup_destination_type === 'rsync' && config?.backup_rsync_host
? `Host: ${config.backup_rsync_host}`
: 'No destination set'}
</p>
</div>
</div>
{config?.backup_retention_days && (
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
<div className="flex items-center space-x-2">
<Info className="h-4 w-4 text-gray-400" />
<span className="text-sm text-gray-600">
Backups retained for {config.backup_retention_days} days
</span>
</div>
</div>
)}
</div>
</Card>
</div>
</div>
);
};
@@ -0,0 +1,411 @@
import React, { useState } from 'react';
import {
Download,
Eye,
Trash2,
Search,
Filter,
CheckCircle,
XCircle,
Clock,
AlertCircle,
FileArchive,
Database,
Image,
HardDrive,
ChevronDown,
ChevronUp,
Calendar,
RefreshCw,
Loader2
} from 'lucide-react';
import { format, formatDistanceToNow } from 'date-fns';
import { toast } from 'react-toastify';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Button, Card, Input, Loading } from '../common';
import { api } from '../../config/api';
const statusIcons = {
completed: { icon: CheckCircle, color: 'text-green-500' },
failed: { icon: XCircle, color: 'text-red-500' },
running: { icon: Loader2, color: 'text-blue-500 animate-spin' },
partial: { icon: AlertCircle, color: 'text-amber-500' }
};
const formatBytes = (bytes) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
export const BackupHistory = () => {
const [expandedRows, setExpandedRows] = useState(new Set());
const [searchTerm, setSearchTerm] = useState('');
const [filterStatus, setFilterStatus] = useState('all');
const [currentPage, setCurrentPage] = useState(1);
const queryClient = useQueryClient();
// Fetch backup history
const { data, isLoading, refetch } = useQuery({
queryKey: ['backup-history', currentPage, searchTerm, filterStatus],
queryFn: async () => {
const params = new URLSearchParams({
page: currentPage,
limit: 20,
...(searchTerm && { search: searchTerm }),
...(filterStatus !== 'all' && { status: filterStatus })
});
const response = await api.get(`/admin/backup/status?${params}`);
return response.data;
}
});
// Delete backup mutation
const deleteMutation = useMutation({
mutationFn: async (backupId) => {
const response = await api.delete(`/admin/backup/runs/${backupId}`);
return response.data;
},
onSuccess: () => {
toast.success('Backup deleted successfully');
queryClient.invalidateQueries({ queryKey: ['backup-history'] });
},
onError: (error) => {
toast.error(error.response?.data?.error || 'Failed to delete backup');
}
});
const toggleRowExpansion = (id) => {
const newExpanded = new Set(expandedRows);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
setExpandedRows(newExpanded);
};
const handleDelete = (backup) => {
if (window.confirm(`Are you sure you want to delete this backup from ${format(new Date(backup.created_at), 'PPP')}?`)) {
deleteMutation.mutate(backup.id);
}
};
if (isLoading) {
return <Loading />;
}
const backups = data?.recentBackups || [];
const pagination = data?.pagination || {};
return (
<div className="space-y-6">
{/* Search and Filters */}
<Card className="p-4">
<div className="flex flex-col sm:flex-row gap-4">
<div className="flex-1">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
type="text"
placeholder="Search backups..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
<div className="flex gap-2">
<select
value={filterStatus}
onChange={(e) => setFilterStatus(e.target.value)}
className="px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-primary focus:border-primary"
>
<option value="all">All Status</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="running">Running</option>
<option value="partial">Partial</option>
</select>
<Button
onClick={() => refetch()}
variant="secondary"
size="sm"
>
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
</Card>
{/* Backup History Table */}
<Card className="overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="bg-gray-50 border-b border-gray-200">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Date & Time
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Type
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Size
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Duration
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
Actions
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{backups.length === 0 ? (
<tr>
<td colSpan={6} className="px-6 py-12 text-center text-gray-500">
<FileArchive className="h-12 w-12 mx-auto mb-3 text-gray-300" />
<p className="text-lg font-medium">No backups found</p>
<p className="text-sm mt-1">Backups will appear here once created</p>
</td>
</tr>
) : (
backups.map((backup) => {
const StatusIcon = statusIcons[backup.status]?.icon || AlertCircle;
const statusColor = statusIcons[backup.status]?.color || 'text-gray-500';
const isExpanded = expandedRows.has(backup.id);
const stats = backup.statistics || {};
return (
<React.Fragment key={backup.id}>
<tr className="hover:bg-gray-50">
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center">
<StatusIcon className={`h-5 w-5 ${statusColor}`} />
<span className="ml-2 text-sm font-medium text-gray-900 capitalize">
{backup.status}
</span>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div>
<p className="text-sm font-medium text-gray-900">
{format(new Date(backup.created_at), 'PPP')}
</p>
<p className="text-xs text-gray-500">
{format(new Date(backup.created_at), 'p')} {formatDistanceToNow(new Date(backup.created_at), { addSuffix: true })}
</p>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 capitalize">
{backup.backup_type || 'Manual'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<p className="text-sm text-gray-900">
{formatBytes(stats.total_size || 0)}
</p>
<p className="text-xs text-gray-500">
{stats.files_processed || 0} files
</p>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
{backup.duration_seconds
? `${Math.round(backup.duration_seconds / 60)}m ${backup.duration_seconds % 60}s`
: '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div className="flex items-center justify-end space-x-2">
<button
onClick={() => toggleRowExpansion(backup.id)}
className="text-gray-400 hover:text-gray-600"
title="View details"
>
{isExpanded ? <ChevronUp size={20} /> : <ChevronDown size={20} />}
</button>
{backup.manifest_path && (
<button
onClick={() => window.open(`/admin/backup/download/${backup.id}`, '_blank')}
className="text-gray-400 hover:text-gray-600"
title="Download backup"
>
<Download size={20} />
</button>
)}
<button
onClick={() => handleDelete(backup)}
className="text-gray-400 hover:text-red-600"
title="Delete backup"
disabled={deleteMutation.isLoading}
>
<Trash2 size={20} />
</button>
</div>
</td>
</tr>
{/* Expanded Details Row */}
{isExpanded && (
<tr>
<td colSpan={6} className="px-6 py-4 bg-gray-50">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{/* Backup Details */}
<div className="space-y-2">
<h4 className="font-medium text-gray-900">Backup Details</h4>
<div className="text-sm space-y-1">
<div className="flex justify-between">
<span className="text-gray-500">Destination:</span>
<span className="text-gray-900">{backup.destination_type || 'Unknown'}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Started:</span>
<span className="text-gray-900">{format(new Date(backup.created_at), 'p')}</span>
</div>
{backup.completed_at && (
<div className="flex justify-between">
<span className="text-gray-500">Completed:</span>
<span className="text-gray-900">{format(new Date(backup.completed_at), 'p')}</span>
</div>
)}
</div>
</div>
{/* Content Backed Up */}
<div className="space-y-2">
<h4 className="font-medium text-gray-900">Content Backed Up</h4>
<div className="space-y-2">
<div className="flex items-center space-x-2">
<Database className={`h-4 w-4 ${stats.database_backed_up ? 'text-green-500' : 'text-gray-300'}`} />
<span className="text-sm text-gray-700">Database</span>
</div>
<div className="flex items-center space-x-2">
<Image className={`h-4 w-4 ${stats.photos_backed_up > 0 ? 'text-green-500' : 'text-gray-300'}`} />
<span className="text-sm text-gray-700">
Photos ({stats.photos_backed_up || 0} of {stats.total_photos || 0})
</span>
</div>
<div className="flex items-center space-x-2">
<FileArchive className={`h-4 w-4 ${stats.archives_backed_up > 0 ? 'text-green-500' : 'text-gray-300'}`} />
<span className="text-sm text-gray-700">
Archives ({stats.archives_backed_up || 0})
</span>
</div>
</div>
</div>
{/* Error Information */}
{backup.error_message && (
<div className="space-y-2">
<h4 className="font-medium text-red-900">Error Details</h4>
<p className="text-sm text-red-700 bg-red-50 p-2 rounded">
{backup.error_message}
</p>
</div>
)}
{/* Manifest Path */}
{backup.manifest_path && (
<div className="space-y-2">
<h4 className="font-medium text-gray-900">Manifest</h4>
<p className="text-sm text-gray-600 font-mono break-all">
{backup.manifest_path}
</p>
</div>
)}
</div>
</td>
</tr>
)}
</React.Fragment>
);
})
)}
</tbody>
</table>
</div>
{/* Pagination */}
{pagination.pages > 1 && (
<div className="bg-white px-4 py-3 border-t border-gray-200 sm:px-6">
<div className="flex items-center justify-between">
<div className="flex-1 flex justify-between sm:hidden">
<Button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
variant="secondary"
size="sm"
>
Previous
</Button>
<Button
onClick={() => setCurrentPage(p => Math.min(pagination.pages, p + 1))}
disabled={currentPage === pagination.pages}
variant="secondary"
size="sm"
>
Next
</Button>
</div>
<div className="hidden sm:flex-1 sm:flex sm:items-center sm:justify-between">
<div>
<p className="text-sm text-gray-700">
Showing <span className="font-medium">{(currentPage - 1) * pagination.limit + 1}</span> to{' '}
<span className="font-medium">
{Math.min(currentPage * pagination.limit, pagination.total)}
</span>{' '}
of <span className="font-medium">{pagination.total}</span> results
</p>
</div>
<div>
<nav className="relative z-0 inline-flex rounded-md shadow-sm -space-x-px">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="relative inline-flex items-center px-2 py-2 rounded-l-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Previous
</button>
{[...Array(Math.min(5, pagination.pages))].map((_, i) => {
const pageNum = i + 1;
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`relative inline-flex items-center px-4 py-2 border text-sm font-medium ${
currentPage === pageNum
? 'z-10 bg-primary-50 border-primary text-primary'
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
}`}
>
{pageNum}
</button>
);
})}
<button
onClick={() => setCurrentPage(p => Math.min(pagination.pages, p + 1))}
disabled={currentPage === pagination.pages}
className="relative inline-flex items-center px-2 py-2 rounded-r-md border border-gray-300 bg-white text-sm font-medium text-gray-500 hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
</nav>
</div>
</div>
</div>
</div>
)}
</Card>
</div>
);
};
+488 -108
View File
@@ -1,7 +1,13 @@
import React, { useState } from 'react';
import React, { useState, useCallback } from 'react';
import { useEditor, EditorContent } from '@tiptap/react';
import StarterKit from '@tiptap/starter-kit';
import Link from '@tiptap/extension-link';
import HardBreak from '@tiptap/extension-hard-break';
import Placeholder from '@tiptap/extension-placeholder';
import CharacterCount from '@tiptap/extension-character-count';
import TextAlign from '@tiptap/extension-text-align';
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight';
import { lowlight } from 'lowlight';
import {
Bold,
Italic,
@@ -10,33 +16,103 @@ import {
Link as LinkIcon,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
Quote,
Code,
Code2,
Minus,
Undo,
Redo
Redo,
RemoveFormatting,
AlignLeft,
AlignCenter,
AlignRight,
AlignJustify,
Eye,
Edit3,
Columns,
Maximize2,
HelpCircle,
Save
} from 'lucide-react';
import { Button } from '../common';
import DOMPurify from 'dompurify';
import '../../styles/prose-overrides.css';
interface CMSEditorProps {
content: string;
onChange: (content: string) => void;
onSave?: () => void;
isSaving?: boolean;
}
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange }) => {
type ViewMode = 'edit' | 'preview' | 'split';
export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange, onSave, isSaving }) => {
const [linkUrl, setLinkUrl] = useState('');
const [showLinkDialog, setShowLinkDialog] = useState(false);
const [viewMode, setViewMode] = useState<ViewMode>('edit');
const [isFullscreen, setIsFullscreen] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [wordCount, setWordCount] = useState(0);
const [charCount, setCharCount] = useState(0);
const editor = useEditor({
extensions: [
StarterKit,
StarterKit.configure({
hardBreak: false, // We'll use the separate HardBreak extension
codeBlock: false, // We'll use CodeBlockLowlight instead
}),
HardBreak.configure({
keepMarks: true,
HTMLAttributes: {
class: 'hard-break',
},
}),
Link.configure({
openOnClick: false,
HTMLAttributes: {
target: '_blank',
rel: 'noopener noreferrer',
},
}),
TextAlign.configure({
types: ['heading', 'paragraph'],
alignments: ['left', 'center', 'right', 'justify'],
defaultAlignment: 'left',
}),
CodeBlockLowlight.configure({
lowlight,
HTMLAttributes: {
class: 'hljs',
},
}),
Placeholder.configure({
placeholder: 'Start typing your content here...',
}),
CharacterCount.configure({
limit: null,
}),
],
content,
onUpdate: ({ editor }) => {
onChange(editor.getHTML());
updateCounts(editor);
},
onCreate: ({ editor }) => {
updateCounts(editor);
},
});
const updateCounts = useCallback((editor: any) => {
const text = editor.state.doc.textContent;
setCharCount(editor.storage.characterCount.characters());
setWordCount(text.trim().split(/\s+/).filter(word => word.length > 0).length);
}, []);
// Update editor content when prop changes
React.useEffect(() => {
if (editor && content !== editor.getHTML()) {
@@ -61,12 +137,14 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange }) => {
active?: boolean;
children: React.ReactNode;
title: string;
}> = ({ onClick, active, children, title }) => (
disabled?: boolean;
}> = ({ onClick, active, children, title, disabled }) => (
<button
onClick={onClick}
className={`p-2 rounded hover:bg-neutral-100 ${
disabled={disabled}
className={`p-2 rounded hover:bg-neutral-100 transition-colors ${
active ? 'bg-primary-100 text-primary-700' : 'text-neutral-700'
}`}
} ${disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
title={title}
type="button"
>
@@ -74,116 +152,418 @@ export const CMSEditor: React.FC<CMSEditorProps> = ({ content, onChange }) => {
</button>
);
const toggleFullscreen = () => {
setIsFullscreen(!isFullscreen);
};
const getPreviewContent = () => {
return DOMPurify.sanitize(editor?.getHTML() || '', {
ALLOWED_TAGS: [
'p', 'br', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
'ul', 'ol', 'li', 'blockquote', 'a', 'em', 'strong',
'code', 'pre', 'hr', 'div', 'span'
],
ALLOWED_ATTR: ['href', 'target', 'rel', 'class', 'style'],
ALLOW_DATA_ATTR: false,
KEEP_CONTENT: true,
ADD_TAGS: ['br'], // Explicitly allow br tags
ADD_ATTR: ['style'], // Allow style for text alignment
});
};
return (
<div className="border border-neutral-300 rounded-lg overflow-hidden">
{/* Toolbar */}
<div className="flex items-center gap-1 p-2 border-b border-neutral-200 bg-neutral-50 flex-wrap">
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
active={editor.isActive('heading', { level: 1 })}
title="Heading 1"
>
<Heading1 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
title="Heading 2"
>
<Heading2 className="w-4 h-4" />
</MenuButton>
<div className={`relative ${isFullscreen ? 'fixed inset-0 z-50 bg-white' : ''}`}>
<div className="border border-neutral-300 rounded-lg overflow-hidden h-full flex flex-col">
{/* Top Toolbar */}
<div className="border-b border-neutral-200 bg-neutral-50">
{/* View Mode Controls */}
<div className="flex items-center justify-between p-2 border-b border-neutral-200">
<div className="flex items-center gap-2">
<button
onClick={() => setViewMode('edit')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'edit'
? 'bg-primary-100 text-primary-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
>
<Edit3 className="w-4 h-4 inline-block mr-1" />
Edit
</button>
<button
onClick={() => setViewMode('preview')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'preview'
? 'bg-primary-100 text-primary-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
>
<Eye className="w-4 h-4 inline-block mr-1" />
Preview
</button>
<button
onClick={() => setViewMode('split')}
className={`px-3 py-1.5 text-sm font-medium rounded transition-colors ${
viewMode === 'split'
? 'bg-primary-100 text-primary-700'
: 'text-neutral-600 hover:bg-neutral-100'
}`}
>
<Columns className="w-4 h-4 inline-block mr-1" />
Split
</button>
</div>
<div className="flex items-center gap-2">
{onSave && (
<Button
size="sm"
onClick={onSave}
isLoading={isSaving}
leftIcon={<Save className="w-4 h-4" />}
>
Save
</Button>
)}
<MenuButton
onClick={() => setShowHelp(true)}
title="Help & Keyboard Shortcuts"
>
<HelpCircle className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={toggleFullscreen}
title={isFullscreen ? "Exit Fullscreen" : "Enter Fullscreen"}
active={isFullscreen}
>
<Maximize2 className="w-4 h-4" />
</MenuButton>
</div>
</div>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
title="Bold"
>
<Bold className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
title="Italic"
>
<Italic className="w-4 h-4" />
</MenuButton>
{/* Formatting Toolbar */}
{viewMode !== 'preview' && (
<div className="flex items-center gap-1 p-2 flex-wrap">
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
active={editor.isActive('heading', { level: 1 })}
title="Heading 1 (Ctrl+Alt+1)"
>
<Heading1 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
active={editor.isActive('heading', { level: 2 })}
title="Heading 2 (Ctrl+Alt+2)"
>
<Heading2 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
active={editor.isActive('heading', { level: 3 })}
title="Heading 3 (Ctrl+Alt+3)"
>
<Heading3 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 4 }).run()}
active={editor.isActive('heading', { level: 4 })}
title="Heading 4 (Ctrl+Alt+4)"
>
<Heading4 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 5 }).run()}
active={editor.isActive('heading', { level: 5 })}
title="Heading 5 (Ctrl+Alt+5)"
>
<Heading5 className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleHeading({ level: 6 }).run()}
active={editor.isActive('heading', { level: 6 })}
title="Heading 6 (Ctrl+Alt+6)"
>
<Heading6 className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
title="Bullet List"
>
<List className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
title="Ordered List"
>
<ListOrdered className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive('bold')}
title="Bold (Ctrl+B)"
>
<Bold className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive('italic')}
title="Italic (Ctrl+I)"
>
<Italic className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleCode().run()}
active={editor.isActive('code')}
title="Inline Code (Ctrl+E)"
>
<Code className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
active={editor.isActive('codeBlock')}
title="Code Block (Ctrl+Alt+C)"
>
<Code2 className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => setShowLinkDialog(true)}
active={editor.isActive('link')}
title="Add Link"
>
<LinkIcon className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().toggleBulletList().run()}
active={editor.isActive('bulletList')}
title="Bullet List (Ctrl+Shift+8)"
>
<List className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleOrderedList().run()}
active={editor.isActive('orderedList')}
title="Numbered List (Ctrl+Shift+9)"
>
<ListOrdered className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().toggleBlockquote().run()}
active={editor.isActive('blockquote')}
title="Blockquote (Ctrl+Shift+B)"
>
<Quote className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().undo().run()}
title="Undo"
>
<Undo className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().redo().run()}
title="Redo"
>
<Redo className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => setShowLinkDialog(true)}
active={editor.isActive('link')}
title="Add Link (Ctrl+K)"
>
<LinkIcon className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setHorizontalRule().run()}
title="Horizontal Rule"
>
<Minus className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('left').run()}
active={editor.isActive({ textAlign: 'left' })}
title="Align Left"
>
<AlignLeft className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('center').run()}
active={editor.isActive({ textAlign: 'center' })}
title="Align Center"
>
<AlignCenter className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('right').run()}
active={editor.isActive({ textAlign: 'right' })}
title="Align Right"
>
<AlignRight className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().setTextAlign('justify').run()}
active={editor.isActive({ textAlign: 'justify' })}
title="Justify"
>
<AlignJustify className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().clearNodes().unsetAllMarks().run()}
title="Clear Formatting"
>
<RemoveFormatting className="w-4 h-4" />
</MenuButton>
<div className="w-px h-6 bg-neutral-300 mx-1" />
<MenuButton
onClick={() => editor.chain().focus().undo().run()}
disabled={!editor.can().undo()}
title="Undo (Ctrl+Z)"
>
<Undo className="w-4 h-4" />
</MenuButton>
<MenuButton
onClick={() => editor.chain().focus().redo().run()}
disabled={!editor.can().redo()}
title="Redo (Ctrl+Y)"
>
<Redo className="w-4 h-4" />
</MenuButton>
</div>
)}
</div>
{/* Link Dialog */}
{showLinkDialog && (
<div className="p-3 bg-primary-50 border-b border-primary-200 flex items-center gap-2">
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addLink()}
placeholder="Enter URL..."
className="flex-1 px-3 py-1 border border-primary-300 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button size="sm" onClick={addLink}>Add Link</Button>
<Button size="sm" variant="outline" onClick={() => {
setShowLinkDialog(false);
setLinkUrl('');
}}>
Cancel
</Button>
</div>
)}
{/* Editor Content Area */}
<div className="flex-1 flex overflow-hidden">
{/* Editor */}
{viewMode !== 'preview' && (
<div className={`${viewMode === 'split' ? 'w-1/2 border-r border-neutral-200' : 'w-full'} overflow-auto`}>
<EditorContent
editor={editor}
className="min-h-[400px] p-4 prose prose-neutral max-w-none focus:outline-none [&_.ProseMirror]:min-h-[400px] [&_.ProseMirror]:outline-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:content-[attr(data-placeholder)] [&_.ProseMirror_p.is-editor-empty:first-child::before]:text-neutral-400 [&_.ProseMirror_p.is-editor-empty:first-child::before]:pointer-events-none [&_.ProseMirror_p.is-editor-empty:first-child::before]:float-left [&_.ProseMirror_p.is-editor-empty:first-child::before]:h-0 [&_.ProseMirror_br.hard-break]:display-block [&_.ProseMirror_br.hard-break]:content-[''] [&_.ProseMirror_br.hard-break]:margin-[0.5em_0] [&_.ProseMirror_pre]:bg-neutral-100 [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:p-4 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_code]:bg-neutral-100 [&_.ProseMirror_code]:rounded [&_.ProseMirror_code]:px-1 [&_.ProseMirror_code]:py-0.5 [&_.ProseMirror_code]:text-sm [&_.ProseMirror_pre_code]:bg-transparent [&_.ProseMirror_pre_code]:p-0"
/>
</div>
)}
{/* Preview */}
{viewMode !== 'edit' && (
<div className={`${viewMode === 'split' ? 'w-1/2' : 'w-full'} overflow-auto bg-neutral-50 p-4`}>
<div
className="prose prose-neutral max-w-none"
dangerouslySetInnerHTML={{ __html: getPreviewContent() }}
/>
</div>
)}
</div>
{/* Status Bar */}
<div className="flex items-center justify-between px-4 py-2 bg-neutral-50 border-t border-neutral-200 text-sm text-neutral-600">
<div className="flex items-center gap-4">
<span>{wordCount} words</span>
<span>{charCount} characters</span>
</div>
<div className="text-xs text-neutral-500">
Press Shift+Enter for line break, Enter for new paragraph
</div>
</div>
</div>
{/* Link Dialog */}
{showLinkDialog && (
<div className="p-3 bg-primary-50 border-b border-primary-200 flex items-center gap-2">
<input
type="url"
value={linkUrl}
onChange={(e) => setLinkUrl(e.target.value)}
onKeyPress={(e) => e.key === 'Enter' && addLink()}
placeholder="Enter URL..."
className="flex-1 px-3 py-1 border border-primary-300 rounded-md focus:ring-2 focus:ring-primary-500"
autoFocus
/>
<Button size="sm" onClick={addLink}>Add Link</Button>
<Button size="sm" variant="outline" onClick={() => {
setShowLinkDialog(false);
setLinkUrl('');
}}>
Cancel
</Button>
{/* Help Modal */}
{showHelp && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-lg max-w-2xl w-full max-h-[80vh] overflow-auto">
<div className="p-6">
<h2 className="text-xl font-semibold mb-4">Editor Help & Keyboard Shortcuts</h2>
<div className="space-y-4">
<div>
<h3 className="font-semibold mb-2">Text Formatting</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+B</kbd> - Bold</div>
<div><kbd>Ctrl+I</kbd> - Italic</div>
<div><kbd>Ctrl+E</kbd> - Inline code</div>
<div><kbd>Ctrl+K</kbd> - Add link</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Headings</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+Alt+1</kbd> - Heading 1</div>
<div><kbd>Ctrl+Alt+2</kbd> - Heading 2</div>
<div><kbd>Ctrl+Alt+3</kbd> - Heading 3</div>
<div><kbd>Ctrl+Alt+4</kbd> - Heading 4</div>
<div><kbd>Ctrl+Alt+5</kbd> - Heading 5</div>
<div><kbd>Ctrl+Alt+6</kbd> - Heading 6</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Lists & Blocks</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+Shift+8</kbd> - Bullet list</div>
<div><kbd>Ctrl+Shift+9</kbd> - Numbered list</div>
<div><kbd>Ctrl+Shift+B</kbd> - Blockquote</div>
<div><kbd>Ctrl+Alt+C</kbd> - Code block</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Text Alignment</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>Click alignment buttons in toolbar</div>
<div>Works on paragraphs and headings</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Line Breaks</h3>
<div className="space-y-1 text-sm">
<div><kbd>Enter</kbd> - New paragraph</div>
<div><kbd>Shift+Enter</kbd> - Line break (preserves formatting)</div>
</div>
</div>
<div>
<h3 className="font-semibold mb-2">Navigation</h3>
<div className="grid grid-cols-2 gap-2 text-sm">
<div><kbd>Ctrl+Z</kbd> - Undo</div>
<div><kbd>Ctrl+Y</kbd> - Redo</div>
</div>
</div>
</div>
<div className="mt-6 flex justify-end">
<Button onClick={() => setShowHelp(false)}>Close</Button>
</div>
</div>
</div>
</div>
)}
{/* Editor */}
<EditorContent
editor={editor}
className="min-h-[300px] p-4 prose prose-neutral max-w-none focus:outline-none"
/>
</div>
);
};
@@ -0,0 +1,797 @@
import React, { useState } from 'react';
import {
RefreshCw,
AlertTriangle,
CheckCircle,
XCircle,
Upload,
HardDrive,
Cloud,
Server,
Database,
Image,
FileArchive,
Info,
ChevronRight,
ChevronLeft,
Loader2,
Shield,
Download,
Eye,
Calendar,
Clock,
AlertCircle
} from 'lucide-react';
import { format } from 'date-fns';
import { toast } from 'react-toastify';
import { useQuery, useMutation } from '@tanstack/react-query';
import { Button, Card, Input, Loading } from '../common';
import { api } from '../../config/api';
const steps = [
{ id: 'source', title: 'Select Source' },
{ id: 'backup', title: 'Choose Backup' },
{ id: 'options', title: 'Restore Options' },
{ id: 'confirm', title: 'Review & Confirm' },
{ id: 'progress', title: 'Restore Progress' }
];
const restoreTypes = [
{
id: 'full',
name: 'Full Restore',
description: 'Restore everything including database, photos, and archives',
icon: RefreshCw,
warning: 'This will replace all current data'
},
{
id: 'database',
name: 'Database Only',
description: 'Restore only the database (settings, events, users)',
icon: Database,
warning: 'Current database will be replaced'
},
{
id: 'files',
name: 'Files Only',
description: 'Restore only photos and archives',
icon: Image,
warning: 'Existing files may be overwritten'
},
{
id: 'selective',
name: 'Selective Restore',
description: 'Choose specific items to restore',
icon: CheckCircle,
warning: 'Only selected items will be restored'
}
];
export const RestoreWizard = () => {
const [currentStep, setCurrentStep] = useState(0);
const [restoreData, setRestoreData] = useState({
source: null,
sourceConfig: {},
selectedBackup: null,
restoreType: 'full',
selectedItems: [],
skipPreBackup: false,
force: false,
encryptionPassphrase: ''
});
const [validationResult, setValidationResult] = useState(null);
// Fetch restore status
const { data: restoreStatus } = useQuery({
queryKey: ['restore-status'],
queryFn: async () => {
const response = await api.get('/admin/restore/status');
return response.data.data;
},
refetchInterval: currentStep === 4 ? 2000 : false // Poll during restore
});
// Fetch available backups
const { data: availableBackups, isLoading: loadingBackups } = useQuery({
queryKey: ['available-backups', restoreData.source, restoreData.sourceConfig],
queryFn: async () => {
const response = await api.post('/admin/restore/list-backups', {
source: restoreData.source,
...restoreData.sourceConfig
});
return response.data.data;
},
enabled: currentStep === 1 && !!restoreData.source
});
// Validate restore
const validateMutation = useMutation({
mutationFn: async () => {
const response = await api.post('/admin/restore/validate', {
source: restoreData.source,
manifestPath: restoreData.selectedBackup.manifest_path,
restoreType: restoreData.restoreType,
selectedItems: restoreData.selectedItems,
...restoreData.sourceConfig
});
return response.data.data;
},
onSuccess: (data) => {
setValidationResult(data);
setCurrentStep(3);
},
onError: (error) => {
toast.error(error.response?.data?.error || 'Validation failed');
}
});
// Start restore
const restoreMutation = useMutation({
mutationFn: async () => {
const response = await api.post('/admin/restore/start', {
source: restoreData.source,
manifestPath: restoreData.selectedBackup.manifest_path,
restoreType: restoreData.restoreType,
selectedItems: restoreData.selectedItems,
skipPreBackup: restoreData.skipPreBackup,
force: restoreData.force,
encryptionPassphrase: restoreData.encryptionPassphrase,
...restoreData.sourceConfig
});
return response.data;
},
onSuccess: () => {
setCurrentStep(4);
toast.success('Restore started successfully');
},
onError: (error) => {
toast.error(error.response?.data?.error || 'Failed to start restore');
}
});
const handleNext = () => {
if (currentStep === 2) {
// Validate before confirmation
validateMutation.mutate();
} else if (currentStep === 3) {
// Start restore
restoreMutation.mutate();
} else {
setCurrentStep(prev => Math.min(prev + 1, steps.length - 1));
}
};
const handleBack = () => {
setCurrentStep(prev => Math.max(prev - 1, 0));
};
const canProceed = () => {
switch (currentStep) {
case 0:
return !!restoreData.source;
case 1:
return !!restoreData.selectedBackup;
case 2:
return !!restoreData.restoreType;
case 3:
return !!validationResult && !validateMutation.isLoading;
default:
return false;
}
};
// Step Components
const renderSourceSelection = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Select Backup Source</h3>
<p className="text-sm text-gray-600">Choose where to restore the backup from</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<button
onClick={() => setRestoreData(prev => ({ ...prev, source: 'local' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.source === 'local'
? 'border-primary bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<HardDrive className={`h-12 w-12 mb-3 mx-auto ${
restoreData.source === 'local' ? 'text-primary' : 'text-gray-400'
}`} />
<h4 className="font-medium text-gray-900">Local Backup</h4>
<p className="text-xs text-gray-500 mt-1">Restore from local filesystem</p>
</button>
<button
onClick={() => setRestoreData(prev => ({ ...prev, source: 's3' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.source === 's3'
? 'border-primary bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Cloud className={`h-12 w-12 mb-3 mx-auto ${
restoreData.source === 's3' ? 'text-primary' : 'text-gray-400'
}`} />
<h4 className="font-medium text-gray-900">S3 Storage</h4>
<p className="text-xs text-gray-500 mt-1">Restore from S3 bucket</p>
</button>
<button
onClick={() => setRestoreData(prev => ({ ...prev, source: 'upload' }))}
className={`p-6 rounded-lg border-2 transition-all ${
restoreData.source === 'upload'
? 'border-primary bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<Upload className={`h-12 w-12 mb-3 mx-auto ${
restoreData.source === 'upload' ? 'text-primary' : 'text-gray-400'
}`} />
<h4 className="font-medium text-gray-900">Upload Backup</h4>
<p className="text-xs text-gray-500 mt-1">Upload a backup file</p>
</button>
</div>
{/* Source-specific configuration */}
{restoreData.source === 's3' && (
<Card className="p-4 space-y-4">
<h4 className="font-medium text-gray-900">S3 Configuration</h4>
<div className="grid grid-cols-2 gap-4">
<Input
placeholder="S3 Endpoint URL"
value={restoreData.sourceConfig.s3Endpoint || ''}
onChange={(e) => setRestoreData(prev => ({
...prev,
sourceConfig: { ...prev.sourceConfig, s3Endpoint: e.target.value }
}))}
/>
<Input
placeholder="Bucket Name"
value={restoreData.sourceConfig.s3Bucket || ''}
onChange={(e) => setRestoreData(prev => ({
...prev,
sourceConfig: { ...prev.sourceConfig, s3Bucket: e.target.value }
}))}
/>
<Input
placeholder="Access Key ID"
value={restoreData.sourceConfig.s3AccessKey || ''}
onChange={(e) => setRestoreData(prev => ({
...prev,
sourceConfig: { ...prev.sourceConfig, s3AccessKey: e.target.value }
}))}
/>
<Input
type="password"
placeholder="Secret Access Key"
value={restoreData.sourceConfig.s3SecretKey || ''}
onChange={(e) => setRestoreData(prev => ({
...prev,
sourceConfig: { ...prev.sourceConfig, s3SecretKey: e.target.value }
}))}
/>
</div>
</Card>
)}
{restoreData.source === 'upload' && (
<Card className="p-4">
<div className="text-center py-8">
<Upload className="h-12 w-12 mx-auto mb-3 text-gray-400" />
<p className="text-sm text-gray-600">Upload functionality coming soon</p>
</div>
</Card>
)}
</div>
);
const renderBackupSelection = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Choose Backup to Restore</h3>
<p className="text-sm text-gray-600">Select from available backups</p>
</div>
{loadingBackups ? (
<Loading />
) : availableBackups?.length === 0 ? (
<Card className="p-8 text-center">
<FileArchive className="h-12 w-12 mx-auto mb-3 text-gray-300" />
<p className="text-gray-500">No backups found in selected source</p>
</Card>
) : (
<div className="space-y-3">
{availableBackups?.map((backup) => (
<Card
key={backup.id}
className={`p-4 cursor-pointer transition-all ${
restoreData.selectedBackup?.id === backup.id
? 'ring-2 ring-primary bg-primary-50'
: 'hover:shadow-md'
}`}
onClick={() => setRestoreData(prev => ({ ...prev, selectedBackup: backup }))}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
<div className={`p-2 rounded-lg ${
backup.status === 'completed' ? 'bg-green-100' : 'bg-amber-100'
}`}>
{backup.status === 'completed' ? (
<CheckCircle className="h-6 w-6 text-green-600" />
) : (
<AlertCircle className="h-6 w-6 text-amber-600" />
)}
</div>
<div>
<p className="font-medium text-gray-900">
{format(new Date(backup.created_at), 'PPP')} at {format(new Date(backup.created_at), 'p')}
</p>
<p className="text-sm text-gray-500">
{backup.backup_type} backup {formatBytes(backup.total_size || 0)}
</p>
</div>
</div>
{backup.encrypted && (
<Shield className="h-5 w-5 text-gray-400" />
)}
</div>
</Card>
))}
</div>
)}
{restoreData.selectedBackup?.encrypted && (
<Card className="p-4 bg-amber-50 border-amber-200">
<div className="flex items-start space-x-3">
<Shield className="h-5 w-5 text-amber-600 mt-0.5" />
<div className="flex-1">
<p className="text-sm font-medium text-amber-900">Encrypted Backup</p>
<p className="text-sm text-amber-700 mt-1">
You'll need to provide the encryption passphrase to restore this backup.
</p>
<Input
type="password"
placeholder="Enter encryption passphrase"
className="mt-3"
value={restoreData.encryptionPassphrase}
onChange={(e) => setRestoreData(prev => ({
...prev,
encryptionPassphrase: e.target.value
}))}
/>
</div>
</div>
</Card>
)}
</div>
);
const renderRestoreOptions = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Restore Options</h3>
<p className="text-sm text-gray-600">Choose what to restore</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{restoreTypes.map((type) => {
const Icon = type.icon;
return (
<button
key={type.id}
onClick={() => setRestoreData(prev => ({ ...prev, restoreType: type.id }))}
className={`p-4 rounded-lg border-2 text-left transition-all ${
restoreData.restoreType === type.id
? 'border-primary bg-primary-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="flex items-start space-x-3">
<Icon className={`h-6 w-6 mt-1 ${
restoreData.restoreType === type.id ? 'text-primary' : 'text-gray-400'
}`} />
<div className="flex-1">
<h4 className="font-medium text-gray-900">{type.name}</h4>
<p className="text-sm text-gray-600 mt-1">{type.description}</p>
<p className="text-xs text-amber-600 mt-2">
<AlertTriangle className="inline h-3 w-3 mr-1" />
{type.warning}
</p>
</div>
</div>
</button>
);
})}
</div>
{/* Additional Options */}
<Card className="p-4 space-y-4">
<h4 className="font-medium text-gray-900">Additional Options</h4>
<label className="flex items-start space-x-3">
<input
type="checkbox"
checked={restoreData.skipPreBackup}
onChange={(e) => setRestoreData(prev => ({
...prev,
skipPreBackup: e.target.checked
}))}
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
/>
<div>
<p className="text-sm font-medium text-gray-700">Skip Pre-Restore Backup</p>
<p className="text-xs text-gray-500">
By default, a backup is created before restore. Check this to skip it.
</p>
</div>
</label>
<label className="flex items-start space-x-3">
<input
type="checkbox"
checked={restoreData.force}
onChange={(e) => setRestoreData(prev => ({
...prev,
force: e.target.checked
}))}
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
/>
<div>
<p className="text-sm font-medium text-gray-700">Force Restore</p>
<p className="text-xs text-gray-500">
Override safety checks and warnings (use with caution)
</p>
</div>
</label>
</Card>
</div>
);
const renderConfirmation = () => (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Review & Confirm</h3>
<p className="text-sm text-gray-600">Please review your restore configuration</p>
</div>
{validationResult ? (
<>
{/* Validation Results */}
<Card className={`p-4 ${
validationResult.validation?.isValid
? 'bg-green-50 border-green-200'
: 'bg-red-50 border-red-200'
}`}>
<div className="flex items-start space-x-3">
{validationResult.validation?.isValid ? (
<CheckCircle className="h-5 w-5 text-green-600 mt-0.5" />
) : (
<XCircle className="h-5 w-5 text-red-600 mt-0.5" />
)}
<div className="flex-1">
<p className={`text-sm font-medium ${
validationResult.validation?.isValid ? 'text-green-900' : 'text-red-900'
}`}>
{validationResult.validation?.isValid
? 'Validation Passed'
: 'Validation Failed'}
</p>
{validationResult.validation?.errors?.length > 0 && (
<ul className="mt-2 text-sm text-red-700 list-disc list-inside">
{validationResult.validation.errors.map((error, idx) => (
<li key={idx}>{error}</li>
))}
</ul>
)}
</div>
</div>
</Card>
{/* Space Check */}
{validationResult.spaceCheck && (
<Card className="p-4">
<h4 className="font-medium text-gray-900 mb-3">Storage Space</h4>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-600">Required:</span>
<span className="font-medium">{formatBytes(validationResult.spaceCheck.required)}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-600">Available:</span>
<span className="font-medium">{formatBytes(validationResult.spaceCheck.available)}</span>
</div>
{!validationResult.spaceCheck.sufficient && (
<p className="text-red-600 text-xs mt-2">
<AlertCircle className="inline h-3 w-3 mr-1" />
Insufficient storage space
</p>
)}
</div>
</Card>
)}
{/* Summary */}
<Card className="p-4">
<h4 className="font-medium text-gray-900 mb-3">Restore Summary</h4>
<dl className="space-y-2 text-sm">
<div className="flex justify-between">
<dt className="text-gray-600">Source:</dt>
<dd className="font-medium capitalize">{restoreData.source}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-600">Backup Date:</dt>
<dd className="font-medium">
{format(new Date(restoreData.selectedBackup.created_at), 'PPp')}
</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-600">Restore Type:</dt>
<dd className="font-medium capitalize">{restoreData.restoreType}</dd>
</div>
<div className="flex justify-between">
<dt className="text-gray-600">Pre-backup:</dt>
<dd className="font-medium">{restoreData.skipPreBackup ? 'Skipped' : 'Enabled'}</dd>
</div>
</dl>
</Card>
{/* Warning */}
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<div className="flex">
<AlertTriangle className="h-5 w-5 text-amber-400 mt-0.5" />
<div className="ml-3">
<h3 className="text-sm font-medium text-amber-800">
Important Notice
</h3>
<p className="mt-1 text-sm text-amber-700">
This restore operation will replace existing data. Make sure you have a current backup
before proceeding. This action cannot be undone.
</p>
</div>
</div>
</div>
</>
) : (
<div className="text-center py-8">
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
<p className="mt-2 text-sm text-gray-600">Validating restore configuration...</p>
</div>
)}
</div>
);
const renderProgress = () => {
const progress = restoreStatus?.currentProgress || {};
const isRunning = restoreStatus?.isRunning;
return (
<div className="space-y-6">
<div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">Restore Progress</h3>
<p className="text-sm text-gray-600">
{isRunning ? 'Restore in progress...' : 'Restore completed'}
</p>
</div>
{/* Progress Bar */}
<Card className="p-6">
<div className="space-y-4">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Overall Progress</span>
<span className="font-medium">{progress.percentage || 0}%</span>
</div>
<div className="w-full bg-gray-200 rounded-full h-3">
<div
className="bg-primary h-3 rounded-full transition-all duration-500"
style={{ width: `${progress.percentage || 0}%` }}
/>
</div>
{progress.currentFile && (
<p className="text-sm text-gray-600">
Current: {progress.currentFile}
</p>
)}
</div>
</Card>
{/* Status Details */}
<Card className="p-6">
<h4 className="font-medium text-gray-900 mb-4">Status Details</h4>
<div className="space-y-3">
{progress.steps?.map((step, idx) => (
<div key={idx} className="flex items-center space-x-3">
{step.status === 'completed' ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : step.status === 'running' ? (
<Loader2 className="h-5 w-5 text-blue-500 animate-spin" />
) : step.status === 'failed' ? (
<XCircle className="h-5 w-5 text-red-500" />
) : (
<Clock className="h-5 w-5 text-gray-300" />
)}
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{step.name}</p>
{step.message && (
<p className="text-xs text-gray-500">{step.message}</p>
)}
</div>
{step.duration && (
<span className="text-xs text-gray-500">{step.duration}</span>
)}
</div>
))}
</div>
</Card>
{/* Logs */}
{progress.logs && progress.logs.length > 0 && (
<Card className="p-6">
<h4 className="font-medium text-gray-900 mb-4">Restore Logs</h4>
<div className="bg-gray-900 rounded-lg p-4 max-h-64 overflow-y-auto">
<pre className="text-xs text-gray-300 font-mono">
{progress.logs.join('\n')}
</pre>
</div>
</Card>
)}
{/* Completion Actions */}
{!isRunning && progress.status === 'completed' && (
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
<div className="flex">
<CheckCircle className="h-5 w-5 text-green-400 mt-0.5" />
<div className="ml-3">
<h3 className="text-sm font-medium text-green-800">
Restore Completed Successfully
</h3>
<p className="mt-1 text-sm text-green-700">
Your data has been restored. Please verify everything is working correctly.
</p>
</div>
</div>
</div>
)}
</div>
);
};
const formatBytes = (bytes) => {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
};
return (
<div className="max-w-4xl mx-auto">
{/* Progress Steps */}
<div className="mb-8">
<nav aria-label="Progress">
<ol className="flex items-center">
{steps.map((step, stepIdx) => (
<li key={step.id} className={`relative ${stepIdx !== steps.length - 1 ? 'pr-8 flex-1' : ''}`}>
<div className="flex items-center">
<div className={`
relative flex h-8 w-8 items-center justify-center rounded-full
${currentStep > stepIdx
? 'bg-primary'
: currentStep === stepIdx
? 'bg-primary'
: 'bg-gray-300'
}
`}>
{currentStep > stepIdx ? (
<CheckCircle className="h-5 w-5 text-white" />
) : (
<span className="text-white text-sm">{stepIdx + 1}</span>
)}
</div>
{stepIdx !== steps.length - 1 && (
<div className={`
absolute top-4 w-full h-0.5
${currentStep > stepIdx ? 'bg-primary' : 'bg-gray-300'}
`} style={{ left: '2rem', right: '-2rem' }} />
)}
</div>
<span className={`
mt-2 text-xs font-medium
${currentStep >= stepIdx ? 'text-gray-900' : 'text-gray-500'}
`}>
{step.title}
</span>
</li>
))}
</ol>
</nav>
</div>
{/* Step Content */}
<Card className="p-6">
{currentStep === 0 && renderSourceSelection()}
{currentStep === 1 && renderBackupSelection()}
{currentStep === 2 && renderRestoreOptions()}
{currentStep === 3 && renderConfirmation()}
{currentStep === 4 && renderProgress()}
</Card>
{/* Navigation Buttons */}
<div className="mt-6 flex justify-between">
<Button
variant="secondary"
onClick={handleBack}
disabled={currentStep === 0 || currentStep === 4}
>
<ChevronLeft className="mr-2 h-4 w-4" />
Back
</Button>
{currentStep < 4 && (
<Button
onClick={handleNext}
disabled={!canProceed() || validateMutation.isLoading || restoreMutation.isLoading}
>
{currentStep === 3 ? (
<>
{restoreMutation.isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Starting...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Start Restore
</>
)}
</>
) : currentStep === 2 ? (
<>
{validateMutation.isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Validating...
</>
) : (
<>
Next
<ChevronRight className="ml-2 h-4 w-4" />
</>
)}
</>
) : (
<>
Next
<ChevronRight className="ml-2 h-4 w-4" />
</>
)}
</Button>
)}
{currentStep === 4 && !restoreStatus?.isRunning && (
<Button
onClick={() => {
setCurrentStep(0);
setRestoreData({
source: null,
sourceConfig: {},
selectedBackup: null,
restoreType: 'full',
selectedItems: [],
skipPreBackup: false,
force: false,
encryptionPassphrase: ''
});
setValidationResult(null);
}}
>
Start New Restore
</Button>
)}
</div>
</div>
);
};
@@ -0,0 +1,64 @@
import React from 'react';
import { HelpCircle } from 'lucide-react';
interface WelcomeMessageEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
}
export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
value,
onChange,
placeholder,
rows = 6
}) => {
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
onChange(e.target.value);
};
// Convert newlines to <br> tags for preview
const getPreviewHtml = () => {
return value
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0)
.join('<br />');
};
return (
<div className="space-y-2">
<div className="relative">
<textarea
value={value}
onChange={handleChange}
placeholder={placeholder}
rows={rows}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm"
/>
<div className="absolute top-2 right-2 text-neutral-400">
<HelpCircle className="w-4 h-4" title="Line breaks will be preserved in emails" />
</div>
</div>
<div className="text-xs text-neutral-500">
Tip: Press Enter to create a new line. Each line will appear as a separate paragraph in emails.
</div>
{value && (
<div className="mt-4">
<p className="text-sm font-medium text-neutral-700 mb-2">Preview:</p>
<div className="p-4 bg-neutral-50 rounded-lg border border-neutral-200">
<div
className="text-sm text-neutral-700 whitespace-pre-wrap"
dangerouslySetInnerHTML={{ __html: getPreviewHtml() }}
/>
</div>
</div>
)}
</div>
);
};
WelcomeMessageEditor.displayName = 'WelcomeMessageEditor';
+6 -1
View File
@@ -8,6 +8,7 @@ export { PhotoUpload } from './PhotoUpload';
export { CategoryManager } from './CategoryManager';
export { EventCategoryManager } from './EventCategoryManager';
export { CMSEditor } from './CMSEditor';
export { WelcomeMessageEditor } from './WelcomeMessageEditor';
export { BulkArchiveModal } from './BulkArchiveModal';
export { MaintenanceBanner } from './MaintenanceBanner';
export { EmailPreviewModal } from './EmailPreviewModal';
@@ -21,4 +22,8 @@ export { ThemeDisplay } from './ThemeDisplay';
export { ThemeEditorModal } from './ThemeEditorModal';
export { HeroPhotoSelector } from './HeroPhotoSelector';
export { PhotoUploadModal } from './PhotoUploadModal';
export { GalleryPreview } from './GalleryPreview';
export { GalleryPreview } from './GalleryPreview';
export { BackupDashboard } from './BackupDashboard';
export { BackupConfiguration } from './BackupConfiguration';
export { BackupHistory } from './BackupHistory';
export { RestoreWizard } from './RestoreWizard';
@@ -46,7 +46,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
}
if (!token) {
console.warn('No auth token found for image:', src);
// No auth token - use fallback
setImageSrc(fallbackSrc || '');
setIsLoading(false);
return;
@@ -69,7 +69,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
? buildResourceUrl(imageUrl)
: imageUrl;
// console.log('Fetching authenticated image:', fullImageUrl);
// Fetch authenticated image
const response = await fetch(fullImageUrl, {
headers: {
'Authorization': `Bearer ${token}`
@@ -85,7 +85,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
setImageSrc(objectUrl);
setIsLoading(false);
} catch (err) {
console.error('Failed to load image:', src, err);
// Image loading failed - use fallback
setError(true);
setImageSrc(fallbackSrc || '');
setIsLoading(false);
@@ -2,9 +2,28 @@ import React from 'react';
import { useTranslation } from 'react-i18next';
import { Globe } from 'lucide-react';
// SVG Flag Components
const GBFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
<path fill="#012169" d="M0 0h640v480H0z"/>
<path fill="#FFF" d="m75 0 244 181L562 0h78v62L400 241l240 178v61h-80L320 301 81 480H0v-60l239-178L0 64V0h75z"/>
<path fill="#C8102E" d="m424 281 216 159v40L369 281h55zm-184 20 6 35L54 480H0l240-179zM640 0v3L391 191l2-44L590 0h50zM0 0l239 176h-60L0 42V0z"/>
<path fill="#FFF" d="M241 0v480h160V0H241zM0 160v160h640V160H0z"/>
<path fill="#C8102E" d="M0 193v96h640v-96H0zM273 0v480h96V0h-96z"/>
</svg>
);
const DEFlag: React.FC<{ className?: string }> = ({ className = "w-5 h-5" }) => (
<svg className={className} viewBox="0 0 640 480" xmlns="http://www.w3.org/2000/svg">
<path fill="#000" d="M0 0h640v160H0z"/>
<path fill="#D00" d="M0 160h640v160H0z"/>
<path fill="#FFCE00" d="M0 320h640v160H0z"/>
</svg>
);
const languages = [
{ code: 'en', name: 'English', flag: '🇬🇧' },
{ code: 'de', name: 'Deutsch', flag: '🇩🇪' },
{ code: 'en', name: 'English', Flag: GBFlag },
{ code: 'de', name: 'Deutsch', Flag: DEFlag },
];
export const LanguageSelector: React.FC = () => {
@@ -25,7 +44,7 @@ export const LanguageSelector: React.FC = () => {
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
>
<Globe className="w-4 h-4" />
<span>{currentLanguage.flag}</span>
<currentLanguage.Flag className="w-5 h-5" />
<span>{currentLanguage.name}</span>
</button>
@@ -41,7 +60,7 @@ export const LanguageSelector: React.FC = () => {
: 'text-neutral-700'
}`}
>
<span className="text-lg">{language.flag}</span>
<language.Flag className="w-5 h-5" />
<span>{language.name}</span>
</button>
))}
@@ -54,16 +54,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
// Fetch photos
const { data, isLoading, error, refetch } = useGalleryPhotos(slug);
// Debug logging
useEffect(() => {
console.log('Event prop:', event);
console.log('Event prop hero_photo_id:', event?.hero_photo_id);
if (data) {
console.log('Gallery data:', data);
console.log('Event data from API:', data.event);
console.log('Hero photo ID from API:', data.event?.hero_photo_id);
}
}, [data, event]);
// Data updates are handled by React Query
const downloadAllMutation = useDownloadAllPhotos();
// Handle window resize
@@ -124,7 +115,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
}
} catch (e) {
console.error('Failed to parse event theme:', e);
// Invalid theme format - use default
// Fall back to global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
@@ -142,10 +133,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
// If there's a hero photo, add it to gallery settings
if (fullEvent.hero_photo_id && themeToApply.gallerySettings) {
themeToApply.gallerySettings.heroImageId = fullEvent.hero_photo_id;
console.log('Setting hero photo ID in existing gallery settings:', fullEvent.hero_photo_id);
// Apply hero photo ID to existing gallery settings
} else if (fullEvent.hero_photo_id) {
themeToApply.gallerySettings = { heroImageId: fullEvent.hero_photo_id };
console.log('Creating gallery settings with hero photo ID:', fullEvent.hero_photo_id);
// Create gallery settings with hero photo ID
}
setTheme(themeToApply);
}, 0);
@@ -368,11 +359,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
headerExtra={(() => {
const items = [];
console.log('Header extra - data loaded:', !!data);
console.log('Header extra - allow uploads:', data?.event?.allow_user_uploads);
console.log('Header extra - showSidebar:', showSidebar);
console.log('Header extra - isMobile:', isMobile);
if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
items.push(
<CountdownTimer key="countdown" expiresAt={event.expires_at} className="mr-2" />
@@ -92,7 +92,7 @@ export const PhotoGrid: React.FC<PhotoGridProps> = ({ photos, slug, categoryId }
const downloadPromises = selectedPhotosList.map(photo =>
galleryService.downloadPhoto(slug, photo.id, photo.filename)
.catch(err => {
console.error(`Failed to download ${photo.filename}:`, err);
// Download failed - error handled by UI
return null;
})
);
@@ -115,7 +115,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
const downloadPromises = selectedPhotosList.map(photo =>
galleryService.downloadPhoto(slug, photo.id, photo.filename)
.catch(err => {
console.error(`Failed to download ${photo.filename}:`, err);
// Download failed - error handled by UI
return null;
})
);
@@ -79,7 +79,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
});
successCount++;
} catch (error: any) {
console.error(`Failed to upload ${file.name}:`, error);
// Upload error handled - user notified via UI
failedCount++;
// Show specific error message
@@ -47,12 +47,12 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
useEffect(() => {
if (photos.length > 0) {
const heroId = gallerySettings.heroImageId;
console.log('HeroGalleryLayout - heroImageId:', heroId, 'photos:', photos.length);
// Process hero layout with provided photos
// If admin has selected a specific hero image, always use it
if (heroId) {
const adminSelectedHero = photos.find(p => p.id === heroId);
console.log('Looking for hero photo with ID:', heroId, 'Found:', adminSelectedHero?.filename);
// Hero photo selected by admin
if (adminSelectedHero) {
setHeroPhoto(adminSelectedHero);
setHasInitialized(true);
+1 -1
View File
@@ -107,7 +107,7 @@ api.interceptors.response.use(
localStorage.removeItem(`gallery_event_${gallerySlug}`);
}
// Don't redirect - let the component handle the auth state
} else {
} else if (galleryMatch) {
// We're not on a gallery page but got a 401 from a gallery API
// This shouldn't happen in normal flow, but if it does, redirect to homepage
window.location.href = '/';
+1 -1
View File
@@ -44,7 +44,7 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
setIsAuthenticated(true);
}
} catch (error) {
console.error('Auth check error:', error);
// Auth check failed - user needs to login
setError('Failed to check authentication');
} finally {
setIsLoading(false);
+1 -1
View File
@@ -75,7 +75,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
localStorage.removeItem(`gallery_token_${currentSlug}`);
}
} catch (error) {
console.error('Failed to parse stored event data');
// Invalid stored data - clear it
localStorage.removeItem(`gallery_event_${currentSlug}`);
localStorage.removeItem(`gallery_token_${currentSlug}`);
}
+15 -3
View File
@@ -1,17 +1,28 @@
import { useTranslation } from 'react-i18next';
import { format as dateFnsFormat, formatDistanceToNow as dateFnsFormatDistanceToNow } from 'date-fns';
import { de, enUS } from 'date-fns/locale';
import { useQuery } from '@tanstack/react-query';
import { settingsService } from '../services/settings.service';
export const useLocalizedDate = () => {
const { i18n } = useTranslation();
// Fetch admin settings to get the date format
const { data: settings } = useQuery({
queryKey: ['admin-settings-general'],
queryFn: () => settingsService.getSettingsByType('general'),
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
const getLocale = () => {
return i18n.language === 'de' ? de : enUS;
};
const format = (date: Date | string, formatStr: string) => {
const format = (date: Date | string, formatStr?: string) => {
const dateObj = typeof date === 'string' ? new Date(date) : date;
return dateFnsFormat(dateObj, formatStr, { locale: getLocale() });
// Use admin-configured date format if available and no format string provided
const dateFormat = formatStr || settings?.general_date_format || 'PPP';
return dateFnsFormat(dateObj, dateFormat, { locale: getLocale() });
};
const formatDistanceToNow = (date: Date | string, options?: { addSuffix?: boolean }) => {
@@ -22,6 +33,7 @@ export const useLocalizedDate = () => {
return {
format,
formatDistanceToNow,
locale: getLocale()
locale: getLocale(),
dateFormat: settings?.general_date_format || 'PPP'
};
};

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