diff --git a/.claudedocs/scans/security-20250713.md b/.claudedocs/scans/security-20250713.md deleted file mode 100644 index fce1bb6..0000000 --- a/.claudedocs/scans/security-20250713.md +++ /dev/null @@ -1,286 +0,0 @@ -# Security Scan Report - Wedding Photo Sharing Application -**Date**: July 13, 2025 -**Scanner**: Claude Security Audit with --security --validate flags -**Overall Risk Level**: MEDIUM-HIGH - -## Executive Summary - -The wedding photo sharing application demonstrates strong security fundamentals with comprehensive input validation, proper authentication mechanisms, and good file security practices. However, several critical issues require immediate attention, particularly around hardcoded secrets, token storage, and Content Security Policy configuration. - -### Security Score: 6.5/10 - -**Strengths**: Excellent input validation, parameterized queries, file security, rate limiting -**Critical Issues**: Hardcoded JWT secrets, localStorage token storage, weak CSP, console logging in production - ---- - -## πŸ”΄ CRITICAL FINDINGS (Immediate Action Required) - -### 1. Hardcoded JWT Secret in Development -- **Location**: Backend `.env` file -- **Risk**: Token forgery, authentication bypass -- **Impact**: Complete authentication compromise -- **Remediation**: - ```bash - # Generate secure secret - openssl rand -base64 32 - # Never commit to repository - echo ".env" >> .gitignore - ``` - -### 2. Gallery Tokens in localStorage -- **Location**: Frontend `api.ts` and auth contexts -- **Risk**: XSS token theft -- **Impact**: Gallery access compromise -- **Remediation**: Move to httpOnly cookies: - ```typescript - Cookies.set(`gallery_token_${slug}`, token, { - httpOnly: true, - secure: true, - sameSite: 'strict' - }); - ``` - -### 3. Weak Content Security Policy -- **Location**: Frontend `nginx.conf` -- **Risk**: XSS, code injection -- **Current**: `unsafe-inline` and `unsafe-eval` allowed -- **Remediation**: Implement strict CSP (see detailed recommendations below) - ---- - -## 🟠 HIGH SEVERITY FINDINGS - -### 1. Console Logging in Production -- **Locations**: 61 instances across frontend -- **Risk**: Information disclosure -- **Impact**: Leaking sensitive data, debugging info -- **Remediation**: Implement environment-aware logging - -### 2. Token Revocation Vulnerability -- **Location**: Backend `tokenRevocation.js` -- **Risk**: Token manipulation -- **Impact**: Bypass revocation checks -- **Remediation**: Verify token signature before decoding - -### 3. Source Maps in Production -- **Location**: Frontend build configuration -- **Risk**: Source code exposure -- **Impact**: Reveals application structure -- **Remediation**: Disable in production builds - -### 4. Missing Security Headers -- **Location**: nginx configuration -- **Missing**: HSTS, Permissions-Policy -- **Impact**: Various client-side attacks -- **Remediation**: Add comprehensive security headers - ---- - -## 🟑 MEDIUM SEVERITY FINDINGS - -### 1. Rate Limiting Bypass Potential -- **Location**: Backend rate limiter -- **Risk**: DoS attacks -- **Current**: JWT validation in rate limiter -- **Remediation**: Use IP-based limiting only - -### 2. Incomplete SQL Injection Protection -- **Location**: Complex dashboard queries -- **Risk**: Potential injection in edge cases -- **Current**: Mostly parameterized -- **Remediation**: Use query builder exclusively - -### 3. Session Management -- **Issue**: No gallery token invalidation on password change -- **Risk**: Persistent access after compromise -- **Remediation**: Implement token revocation - -### 4. Path Traversal in Gallery Slugs -- **Location**: Frontend gallery routes -- **Risk**: Directory traversal attempts -- **Remediation**: Validate and sanitize slugs - ---- - -## 🟒 LOW SEVERITY FINDINGS - -### 1. Verbose Error Messages -- **Location**: Multiple API endpoints -- **Risk**: Information disclosure -- **Remediation**: Generic client errors, detailed server logs - -### 2. Weak Gallery Passwords -- **Current**: zxcvbn score 2/4 allowed -- **Risk**: Brute force attacks -- **Remediation**: Increase to score 3/4 - -### 3. Missing File Size Validation -- **Location**: Frontend upload components -- **Risk**: DoS via large uploads -- **Remediation**: Add client-side size checks - ---- - -## βœ… SECURITY STRENGTHS - -### Authentication & Authorization -- JWT with proper expiration (24h/7d) -- Token type validation -- IP tracking and validation -- Password change detection -- Token revocation system -- Bcrypt with 12 rounds -- zxcvbn password strength checking - -### Input Validation & SQL Security -- express-validator on all endpoints -- Parameterized queries via Knex -- SQL injection protection utilities -- Path traversal prevention -- Comprehensive input sanitization - -### File Security -- Magic number verification -- MIME type validation -- Safe filename generation -- Directory traversal protection -- File extension whitelist - -### Rate Limiting & DoS Protection -- General: 100 req/15min -- Auth endpoints: 5 req/15min -- Account lockout after failed attempts -- Suspicious activity detection - -### Frontend Security -- React's built-in XSS protection -- DOMPurify for HTML content -- No eval() or innerHTML usage -- Proper error boundaries -- ReCAPTCHA integration - ---- - -## πŸ“Š DEPENDENCY ANALYSIS - -### Current Status -- **Backend**: 0 vulnerabilities (691 packages) -- **Frontend**: 0 vulnerabilities (434 packages) - -### Recommended Updates -1. **bcrypt** 5.1.1 β†’ 6.0.0 (performance, compatibility) -2. **helmet** 7.2.0 β†’ 8.1.0 (new security features) -3. **@tiptap** 2.x β†’ 3.x (security improvements) - -### Supply Chain Assessment -- All major dependencies from trusted sources -- No typosquatting detected -- Regular maintenance observed -- MIT/ISC/Apache licenses only - ---- - -## πŸ› οΈ REMEDIATION PLAN - -### Phase 1: Critical (Within 24 hours) -1. Replace hardcoded JWT secret with secure random value -2. Move gallery tokens from localStorage to httpOnly cookies -3. Implement strict CSP without unsafe-eval -4. Remove or wrap console.log statements - -### Phase 2: High Priority (Within 1 week) -1. Disable source maps in production -2. Add missing security headers (HSTS, Permissions-Policy) -3. Fix token revocation vulnerability -4. Update critical dependencies (bcrypt, helmet) - -### Phase 3: Medium Priority (Within 1 month) -1. Implement comprehensive logging strategy -2. Add gallery slug validation -3. Enhance rate limiting logic -4. Implement session invalidation on password change - -### Phase 4: Ongoing -1. Weekly dependency scanning -2. Implement security testing in CI/CD -3. Regular penetration testing -4. Security awareness training - ---- - -## πŸ”’ RECOMMENDED CSP CONFIGURATION - -```nginx -add_header Content-Security-Policy " - default-src 'self'; - script-src 'self' 'nonce-{RANDOM}' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; - style-src 'self' 'unsafe-inline'; - img-src 'self' data: blob: https:; - font-src 'self'; - connect-src 'self' https://analytics.domain.com; - frame-src https://www.google.com/recaptcha/; - object-src 'none'; - base-uri 'self'; - form-action 'self'; - frame-ancestors 'none'; - upgrade-insecure-requests; -" always; -``` - ---- - -## πŸš€ SECURITY IMPROVEMENTS ROADMAP - -### Immediate Implementation -```bash -# 1. Generate secure secrets -openssl rand -base64 32 > jwt-secret.txt - -# 2. Update dependencies -cd backend && npm install bcrypt@^6.0.0 helmet@^8.1.0 -cd ../frontend && npm update - -# 3. Add security scanning -npm install -D npm-audit-resolver -``` - -### CI/CD Integration -```yaml -# Add to CI pipeline -- name: Security Scan - run: | - npm audit --audit-level=moderate - npm run test:security -``` - -### Monitoring & Alerting -1. Implement fail2ban for repeated auth failures -2. Set up log analysis for suspicious patterns -3. Configure alerts for security events -4. Regular vulnerability scanning - ---- - -## πŸ“‹ COMPLIANCE CHECKLIST - -- [ ] OWASP Top 10 addressed -- [ ] GDPR compliance (data minimization, right to erasure) -- [ ] Security headers implemented -- [ ] Dependency scanning automated -- [ ] Incident response plan documented -- [ ] Security documentation maintained -- [ ] Regular security reviews scheduled - ---- - -## 🎯 CONCLUSION - -The wedding photo sharing application has a solid security foundation with excellent input validation and authentication mechanisms. However, operational security practices need immediate attention. The critical issues around secret management and token storage must be addressed before production deployment. - -Implementing the recommended fixes will raise the security score from 6.5/10 to approximately 8.5/10, providing a robust and secure platform for wedding photo sharing. - ---- - -*Generated by Claude Security Scanner v1.0* -*Next scan recommended: After Phase 1 remediation completion* \ No newline at end of file diff --git a/.drone.yml b/.drone.yml index df83cb0..c01c09f 100644 --- a/.drone.yml +++ b/.drone.yml @@ -72,6 +72,43 @@ 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} + + trigger: event: - tag \ No newline at end of file diff --git a/.env.example b/.env.example index b008cf9..7636892 100644 --- a/.env.example +++ b/.env.example @@ -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= \ No newline at end of file +# 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) \ No newline at end of file diff --git a/.env.production.example b/.env.production.example index 3812f0a..08246b3 100644 --- a/.env.production.example +++ b/.env.production.example @@ -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 + +# 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 + +# ============================================ +# 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 \ No newline at end of file +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 \ No newline at end of file diff --git a/.gitea/workflows/mirror-to-github.yml b/.gitea/workflows/mirror-to-github.yml index 86f1928..d77cec4 100644 --- a/.gitea/workflows/mirror-to-github.yml +++ b/.gitea/workflows/mirror-to-github.yml @@ -13,55 +13,60 @@ jobs: - name: Checkout repository uses: actions/checkout@v3 with: - fetch-depth: 0 # Full history needed for mirroring + fetch-depth: 0 # Full history for proper mirroring - name: Setup Git run: | git config --global user.name "the-luap" git config --global user.email "paul-nothaft@hotmail.de" - - name: Debug - Show current branch and status + - name: Remove sensitive files and directories run: | - echo "Current branch:" - git branch -a - echo "Git status:" - git status - echo "Remote info:" - git remote -v - - - name: Create filtered branch - run: | - # Clean up any existing github-mirror branch - git branch -D github-mirror || true + echo "Current files before cleanup:" + ls -la | head -10 || true + echo "..." - # Create a new branch for GitHub - 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 + # 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 + rm -rf clean-git-history.sh || true + rm -rf frontend/.swarm/ || true + rm -rf backend/.hive-mind/ || true + rm -rf data/ || true + rm -rf certbot/ || true - - # Commit the changes - git commit -m "Remove sensitive files for GitHub mirror" || true + + echo "Sensitive files removal completed" + + # Add and commit the cleanup if there are changes + git add -A + if ! git diff --cached --quiet; then + git commit -m "chore: remove sensitive files for GitHub mirror" + echo "βœ… Committed cleanup of sensitive files" + else + echo "βœ… No sensitive files to remove" + fi + + echo "Final file structure (top level):" + ls -la | head -10 || true - name: Check GitHub token env: @@ -88,12 +93,13 @@ jobs: echo "GitHub remote added:" git remote -v - # Force push the filtered branch to GitHub main + # Push to GitHub main branch echo "Pushing to GitHub..." - git push github github-mirror:main --force - echo "Push completed successfully!" + git push github main --force + echo "βœ… Push to GitHub completed!" - name: Workflow completed run: | echo "βœ… Mirror to GitHub workflow completed successfully!" - echo "Check https://github.com/the-luap/picpeak to verify the mirror." \ No newline at end of file + echo "πŸ“Š Repository mirrored to: https://github.com/the-luap/picpeak" + echo "πŸ”’ Sensitive files have been removed from the mirror" \ No newline at end of file diff --git a/.gitea/workflows/version-and-release.yml b/.gitea/workflows/version-and-release.yml index ebd5f8a..d04bb9d 100644 --- a/.gitea/workflows/version-and-release.yml +++ b/.gitea/workflows/version-and-release.yml @@ -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' diff --git a/.gitignore b/.gitignore index f3fa5ce..22bdbc6 100644 --- a/.gitignore +++ b/.gitignore @@ -48,9 +48,15 @@ coverage/ *.tmp *.temp +# Backup and test directories +backups/ +test-archiver/ + # Keep directory structure !storage/events/active/.gitkeep !storage/events/archived/.gitkeep !storage/thumbnails/.gitkeep !data/.gitkeep !logs/.gitkeep + +PRODUCTION_DEPLOYMENT_GUIDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index f878c85..ae193dd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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% \ No newline at end of file +- 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 \ No newline at end of file diff --git a/PRODUCTION_DEPLOYMENT.md b/PRODUCTION_DEPLOYMENT.md deleted file mode 100644 index 2f8ab13..0000000 --- a/PRODUCTION_DEPLOYMENT.md +++ /dev/null @@ -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 \ No newline at end of file diff --git a/PRODUCTION_DEPLOYMENT_GUIDE.md b/PRODUCTION_DEPLOYMENT_GUIDE.md index 7bf26c4..9718db6 100644 --- a/PRODUCTION_DEPLOYMENT_GUIDE.md +++ b/PRODUCTION_DEPLOYMENT_GUIDE.md @@ -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= DB_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 -# Umami Analytics (Optional) -UMAMI_URL=https://analytics.yourdomain.com -UMAMI_WEBSITE_ID=your-website-id -UMAMI_HASH_SALT= +# 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 +# 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 diff --git a/README.md b/README.md index c94a63c..120be1a 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,20 @@ Perfect for: - **Email**: SMTP with customizable templates - **Analytics**: Privacy-focused with Umami integration +## πŸ’» System Requirements + +### Minimum Requirements +- **CPU**: 2 CPU cores +- **RAM**: 2GB minimum +- **Storage**: 20GB minimum (plus photo storage needs) +- **OS**: Linux (Ubuntu 20.04+), macOS, or Windows with WSL2 +- **Node.js**: v18.0.0 or higher +- **Database**: SQLite (included) or PostgreSQL 12+ + +### Docker Requirements (Recommended) +- **Docker**: v20.10.0+ +- **Docker Compose**: v2.0.0+ + ## 🀝 Contributing We love contributions! PicPeak is built by photographers, for photographers. Whether you're fixing bugs, adding features, or improving documentation, your help is welcome. @@ -159,6 +173,20 @@ Organize and manage your photo galleries with intuitive event management tools. +## πŸ—ΊοΈ 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. diff --git a/backend/.env.example b/backend/.env.example index e4b74a7..285e541 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 \ No newline at end of file diff --git a/backend/__tests__/README.md b/backend/__tests__/README.md new file mode 100644 index 0000000..c4bda50 --- /dev/null +++ b/backend/__tests__/README.md @@ -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 \ No newline at end of file diff --git a/backend/__tests__/integration/backup-s3.test.js b/backend/__tests__/integration/backup-s3.test.js new file mode 100644 index 0000000..a300c47 --- /dev/null +++ b/backend/__tests__/integration/backup-s3.test.js @@ -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); + } + } +}); \ No newline at end of file diff --git a/backend/__tests__/services/backupService.enhanced.test.js b/backend/__tests__/services/backupService.enhanced.test.js new file mode 100644 index 0000000..e680e71 --- /dev/null +++ b/backend/__tests__/services/backupService.enhanced.test.js @@ -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) + ); + }); + }); +}); \ No newline at end of file diff --git a/backend/data/photo_sharing.db b/backend/data/photo_sharing.db index 9e37c51..84e6fe5 100644 Binary files a/backend/data/photo_sharing.db and b/backend/data/photo_sharing.db differ diff --git a/backend/knexfile.js b/backend/knexfile.js index 598d4fb..7264510 100644 --- a/backend/knexfile.js +++ b/backend/knexfile.js @@ -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, diff --git a/backend/migrations/014_add_default_welcome_message.js b/backend/migrations/014_add_default_welcome_message.js index 37e4bc2..c455437 100644 --- a/backend/migrations/014_add_default_welcome_message.js +++ b/backend/migrations/014_add_default_welcome_message.js @@ -8,8 +8,7 @@ exports.up = async function(knex) { await knex('app_settings').insert({ setting_key: 'general_default_welcome_message', setting_value: JSON.stringify('Thank you for using our photo sharing service! We hope you enjoy your photos.'), - setting_type: 'general', - updated_at: new Date() + setting_type: 'general' }); } diff --git a/backend/migrations/027_add_rate_limit_settings.js b/backend/migrations/027_add_rate_limit_settings.js index 8fa5d9b..f26621a 100644 --- a/backend/migrations/027_add_rate_limit_settings.js +++ b/backend/migrations/027_add_rate_limit_settings.js @@ -41,8 +41,7 @@ exports.up = async function(knex) { if (!exists) { await knex('app_settings').insert({ - ...setting, - updated_at: knex.fn.now() + ...setting }); } } diff --git a/backend/migrations/029_add_backup_service_tables.js b/backend/migrations/029_add_backup_service_tables.js new file mode 100644 index 0000000..10d697d --- /dev/null +++ b/backend/migrations/029_add_backup_service_tables.js @@ -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: `

Backup Failed

+

The scheduled backup has failed and requires immediate attention.

+

Error Details:

+
    +
  • Start Time: {{start_time}}
  • +
  • Backup Type: {{backup_type}}
  • +
  • Error: {{error_message}}
  • +
+

Please check the system logs for more details and resolve the issue as soon as possible.

`, + body_html_de: `

Backup fehlgeschlagen

+

Das geplante Backup ist fehlgeschlagen und erfordert sofortige Aufmerksamkeit.

+

Fehlerdetails:

+
    +
  • Startzeit: {{start_time}}
  • +
  • Backup-Typ: {{backup_type}}
  • +
  • Fehler: {{error_message}}
  • +
+

Bitte ΓΌberprΓΌfen Sie die Systemprotokolle fΓΌr weitere Details und beheben Sie das Problem so schnell wie mΓΆglich.

`, + 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: `

Backup Completed

+

The scheduled backup has been completed successfully.

+

Backup Summary:

+
    +
  • Start Time: {{start_time}}
  • +
  • Duration: {{duration}}
  • +
  • Files Backed Up: {{files_count}}
  • +
  • Total Size: {{total_size}}
  • +
  • Backup Type: {{backup_type}}
  • +
`, + body_html_de: `

Backup abgeschlossen

+

Das geplante Backup wurde erfolgreich abgeschlossen.

+

Backup-Zusammenfassung:

+
    +
  • Startzeit: {{start_time}}
  • +
  • Dauer: {{duration}}
  • +
  • Gesicherte Dateien: {{files_count}}
  • +
  • Gesamtgrâße: {{total_size}}
  • +
  • Backup-Typ: {{backup_type}}
  • +
`, + 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 }; \ No newline at end of file diff --git a/backend/migrations/030_add_database_backup_tables.js b/backend/migrations/030_add_database_backup_tables.js new file mode 100644 index 0000000..cc99100 --- /dev/null +++ b/backend/migrations/030_add_database_backup_tables.js @@ -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: `

Database Backup Failed

+

The scheduled database backup has failed and requires immediate attention.

+

Error Details:

+
    +
  • Backup Type: {{backup_type}}
  • +
  • Timestamp: {{timestamp}}
  • +
  • Error: {{error_message}}
  • +
+

This is a critical issue that could affect disaster recovery. Please investigate immediately.

`, + body_html_de: `

Datenbank-Backup fehlgeschlagen

+

Das geplante Datenbank-Backup ist fehlgeschlagen und erfordert sofortige Aufmerksamkeit.

+

Fehlerdetails:

+
    +
  • Backup-Typ: {{backup_type}}
  • +
  • Zeitstempel: {{timestamp}}
  • +
  • Fehler: {{error_message}}
  • +
+

Dies ist ein kritisches Problem, das die Disaster-Recovery beeintrΓ€chtigen kΓΆnnte. Bitte untersuchen Sie es sofort.

`, + 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: `

Database Backup Completed

+

The scheduled database backup has been completed successfully.

+

Backup Summary:

+
    +
  • Backup Type: {{backup_type}}
  • +
  • Duration: {{duration}}
  • +
  • File Size: {{file_size}}
  • +
  • Compression Ratio: {{compression_ratio}}
  • +
  • File Path: {{file_path}}
  • +
`, + body_html_de: `

Datenbank-Backup abgeschlossen

+

Das geplante Datenbank-Backup wurde erfolgreich abgeschlossen.

+

Backup-Zusammenfassung:

+
    +
  • Backup-Typ: {{backup_type}}
  • +
  • Dauer: {{duration}}
  • +
  • Dateigrâße: {{file_size}}
  • +
  • KomprimierungsverhΓ€ltnis: {{compression_ratio}}
  • +
  • Dateipfad: {{file_path}}
  • +
`, + 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 }; \ No newline at end of file diff --git a/backend/migrations/031_add_backup_manifest_columns.js b/backend/migrations/031_add_backup_manifest_columns.js new file mode 100644 index 0000000..5f075c4 --- /dev/null +++ b/backend/migrations/031_add_backup_manifest_columns.js @@ -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 }; \ No newline at end of file diff --git a/backend/migrations/032_add_restore_runs_table.js b/backend/migrations/032_add_restore_runs_table.js new file mode 100644 index 0000000..5154074 --- /dev/null +++ b/backend/migrations/032_add_restore_runs_table.js @@ -0,0 +1,259 @@ +// No helpers needed for this migration + +/** + * 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(false); + table.boolean('was_rollback_attempted').defaultTo(false); + table.boolean('was_successful').defaultTo(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(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: JSON.stringify(false), + setting_type: 'restore' + }, + { + setting_key: 'restore_require_pre_backup', + setting_value: JSON.stringify(true), + setting_type: 'restore' + }, + { + setting_key: 'restore_max_file_size_mb', + setting_value: '5000', + setting_type: 'restore' + }, + { + setting_key: 'restore_verify_checksums', + setting_value: JSON.stringify(true), + setting_type: 'restore' + }, + { + setting_key: 'restore_email_on_completion', + setting_value: JSON.stringify(true), + setting_type: 'restore' + }, + { + setting_key: 'restore_retention_days', + setting_value: '30', + setting_type: 'restore' + } + ]); + + // Add new email templates for restore notifications + const emailTemplates = [ + { + template_key: 'restore_completed', + subject_en: 'βœ… Restore Completed Successfully', + subject_de: 'βœ… Wiederherstellung erfolgreich abgeschlossen', + body_html_en: `

Restore Operation Completed

+

A restore operation has completed successfully.

+ +

Details:

+
    +
  • Restore Type: {{restore_type}}
  • +
  • Duration: {{duration}}
  • +
  • Files Restored: {{files_restored}}
  • +
  • Backup ID: {{backup_id}}
  • +
  • Timestamp: {{timestamp}}
  • +
+ +

Please verify that all systems are functioning correctly after the restore.

`, + body_html_de: `

Wiederherstellungsvorgang abgeschlossen

+

Ein Wiederherstellungsvorgang wurde erfolgreich abgeschlossen.

+ +

Details:

+
    +
  • Wiederherstellungstyp: {{restore_type}}
  • +
  • Dauer: {{duration}}
  • +
  • Wiederhergestellte Dateien: {{files_restored}}
  • +
  • Backup-ID: {{backup_id}}
  • +
  • Zeitstempel: {{timestamp}}
  • +
+ +

Bitte überprüfen Sie, ob alle Systeme nach der Wiederherstellung ordnungsgemÀß funktionieren.

`, + body_text_en: `Restore Operation Completed + +A restore operation has completed successfully. + +Details: +- Restore Type: {{restore_type}} +- Duration: {{duration}} +- Files Restored: {{files_restored}} +- Backup ID: {{backup_id}} +- Timestamp: {{timestamp}} + +Please verify that all systems are functioning correctly after the restore.`, + body_text_de: `Wiederherstellungsvorgang abgeschlossen + +Ein Wiederherstellungsvorgang wurde erfolgreich abgeschlossen. + +Details: +- Wiederherstellungstyp: {{restore_type}} +- Dauer: {{duration}} +- Wiederhergestellte Dateien: {{files_restored}} +- Backup-ID: {{backup_id}} +- Zeitstempel: {{timestamp}} + +Bitte überprüfen Sie, ob alle Systeme nach der Wiederherstellung ordnungsgemÀß funktionieren.`, + variables: JSON.stringify(['restore_type', 'duration', 'files_restored', 'backup_id', 'timestamp']) + }, + { + template_key: 'restore_failed', + subject_en: '❌ Restore Operation Failed', + subject_de: '❌ Wiederherstellungsvorgang fehlgeschlagen', + body_html_en: `

Restore Operation Failed

+

A restore operation has failed and requires attention.

+ +

Details:

+
    +
  • Restore Type: {{restore_type}}
  • +
  • Error: {{error_message}}
  • +
  • Timestamp: {{timestamp}}
  • +
+ +

Please check the system logs for more details and take appropriate action.

+ +

Important: If a pre-restore backup was created, it may be used for recovery.

`, + body_html_de: `

Wiederherstellungsvorgang fehlgeschlagen

+

Ein Wiederherstellungsvorgang ist fehlgeschlagen und erfordert Ihre Aufmerksamkeit.

+ +

Details:

+
    +
  • Wiederherstellungstyp: {{restore_type}}
  • +
  • Fehler: {{error_message}}
  • +
  • Zeitstempel: {{timestamp}}
  • +
+ +

Bitte überprüfen Sie die Systemprotokolle für weitere Details und ergreifen Sie entsprechende Maßnahmen.

+ +

Wichtig: Falls ein Backup vor der Wiederherstellung erstellt wurde, kann es zur Wiederherstellung verwendet werden.

`, + body_text_en: `Restore Operation Failed + +A restore operation has failed and requires attention. + +Details: +- Restore Type: {{restore_type}} +- Error: {{error_message}} +- Timestamp: {{timestamp}} + +Please check the system logs for more details and take appropriate action. + +Important: If a pre-restore backup was created, it may be used for recovery.`, + body_text_de: `Wiederherstellungsvorgang fehlgeschlagen + +Ein Wiederherstellungsvorgang ist fehlgeschlagen und erfordert Ihre Aufmerksamkeit. + +Details: +- Wiederherstellungstyp: {{restore_type}} +- Fehler: {{error_message}} +- Zeitstempel: {{timestamp}} + +Bitte ΓΌberprΓΌfen Sie die Systemprotokolle fΓΌr weitere Details und ergreifen Sie entsprechende Maßnahmen. + +Wichtig: Falls ein Backup vor der Wiederherstellung erstellt wurde, kann es zur Wiederherstellung verwendet werden.`, + variables: JSON.stringify(['restore_type', 'error_message', 'timestamp']) + } + ]; + + await knex('email_templates').insert(emailTemplates); +}; + +exports.down = async function(knex) { + // Remove email templates + await knex('email_templates') + .whereIn('template_key', ['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'); +}; \ No newline at end of file diff --git a/backend/migrations/033_add_gallery_feedback.js b/backend/migrations/033_add_gallery_feedback.js new file mode 100644 index 0000000..4c1a7ca --- /dev/null +++ b/backend/migrations/033_add_gallery_feedback.js @@ -0,0 +1,129 @@ +// No helpers needed for boolean values + +exports.up = async function(knex) { + console.log('Adding gallery feedback tables...'); + + // Create event_feedback_settings table + await knex.schema.createTable('event_feedback_settings', (table) => { + table.increments('id').primary(); + table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); + table.boolean('feedback_enabled').defaultTo(false); + table.boolean('allow_ratings').defaultTo(true); + table.boolean('allow_likes').defaultTo(true); + table.boolean('allow_comments').defaultTo(false); + table.boolean('allow_favorites').defaultTo(true); + table.boolean('require_name_email').defaultTo(false); + table.boolean('moderate_comments').defaultTo(true); + table.boolean('show_feedback_to_guests').defaultTo(true); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + table.unique(['event_id']); + }); + + // Create photo_feedback table + await knex.schema.createTable('photo_feedback', (table) => { + table.increments('id').primary(); + table.integer('photo_id').references('id').inTable('photos').onDelete('CASCADE'); + table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); + table.string('feedback_type', 20).notNullable(); + table.integer('rating'); + table.text('comment_text'); + table.string('guest_name', 100); + table.string('guest_email', 255); + table.string('guest_identifier', 64); + table.string('ip_address', 45); + table.text('user_agent'); + table.boolean('is_approved').defaultTo(true); + table.boolean('is_hidden').defaultTo(false); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.timestamp('updated_at').defaultTo(knex.fn.now()); + + // Add indexes + table.index(['photo_id']); + table.index(['event_id']); + table.index(['feedback_type']); + table.index(['guest_identifier']); + + // Add check constraint for rating (PostgreSQL) + if (knex.client.config.client === 'pg') { + table.check('?? >= 1 AND ?? <= 5', ['rating', 'rating']); + } + }); + + // Create feedback_rate_limits table + await knex.schema.createTable('feedback_rate_limits', (table) => { + table.increments('id').primary(); + table.string('identifier', 64).notNullable(); + table.integer('event_id').references('id').inTable('events').onDelete('CASCADE'); + table.string('action_type', 20).notNullable(); + table.integer('action_count').defaultTo(1); + table.timestamp('window_start').defaultTo(knex.fn.now()); + + // Add indexes + table.index(['identifier', 'event_id', 'action_type']); + table.index(['window_start']); + }); + + // Create feedback_word_filters table + await knex.schema.createTable('feedback_word_filters', (table) => { + table.increments('id').primary(); + table.string('word', 100).notNullable(); + table.string('severity', 20).defaultTo('moderate'); + table.boolean('is_active').defaultTo(true); + table.timestamp('created_at').defaultTo(knex.fn.now()); + table.unique(['word']); + }); + + // Add feedback summary columns to photos table + await knex.schema.alterTable('photos', (table) => { + table.integer('feedback_count').defaultTo(0); + table.integer('like_count').defaultTo(0); + table.decimal('average_rating', 3, 2).defaultTo(0); + table.integer('favorite_count').defaultTo(0); + }); + + // Add feedback notification settings to app_settings + await knex('app_settings').insert([ + { + setting_key: 'feedback_notification_email', + setting_value: JSON.stringify(''), + setting_type: 'feedback' + }, + { + setting_key: 'feedback_rate_limits', + setting_value: JSON.stringify({ + rating: { max: 100, window: 3600 }, // 100 ratings per hour + comment: { max: 20, window: 3600 }, // 20 comments per hour + like: { max: 200, window: 3600 } // 200 likes per hour + }), + setting_type: 'feedback' + } + ]); + + console.log('Gallery feedback tables created successfully'); +}; + +exports.down = async function(knex) { + console.log('Removing gallery feedback tables...'); + + // Remove feedback settings from app_settings + await knex('app_settings') + .whereIn('setting_key', ['feedback_notification_email', 'feedback_rate_limits']) + .delete(); + + // Remove feedback columns from photos table + await knex.schema.alterTable('photos', (table) => { + table.dropColumn('feedback_count'); + table.dropColumn('like_count'); + table.dropColumn('average_rating'); + table.dropColumn('favorite_count'); + }); + + // Drop tables in reverse order + await knex.schema.dropTableIfExists('feedback_word_filters'); + await knex.schema.dropTableIfExists('feedback_rate_limits'); + await knex.schema.dropTableIfExists('photo_feedback'); + await knex.schema.dropTableIfExists('event_feedback_settings'); + + console.log('Gallery feedback tables removed'); +}; \ No newline at end of file diff --git a/backend/migrations/034_add_version_to_backups.js b/backend/migrations/034_add_version_to_backups.js new file mode 100644 index 0000000..ef8155e --- /dev/null +++ b/backend/migrations/034_add_version_to_backups.js @@ -0,0 +1,139 @@ +const { db } = require('../src/database/db'); + +async function up() { + console.log('Adding version tracking to backup tables...'); + + // Add version columns to database_backup_runs table + const hasDatabaseBackupRunsTable = await db.schema.hasTable('database_backup_runs'); + if (hasDatabaseBackupRunsTable) { + const hasAppVersion = await db.schema.hasColumn('database_backup_runs', 'app_version'); + if (!hasAppVersion) { + await db.schema.alterTable('database_backup_runs', (table) => { + table.string('app_version'); // Application version + table.string('node_version'); // Node.js version + table.string('db_schema_version'); // Database schema version (migration name) + table.json('environment_info'); // Additional environment information + }); + console.log('Added version columns to database_backup_runs table'); + } + } + + // Add version columns to backup_runs table (file backups) + const hasBackupRunsTable = await db.schema.hasTable('backup_runs'); + if (hasBackupRunsTable) { + const hasAppVersion = await db.schema.hasColumn('backup_runs', 'app_version'); + if (!hasAppVersion) { + await db.schema.alterTable('backup_runs', (table) => { + table.string('app_version'); // Application version + table.string('node_version'); // Node.js version + table.string('db_schema_version'); // Database schema version + table.json('manifest_info'); // Manifest summary information + }); + console.log('Added version columns to backup_runs table'); + } + } + + // Add restore tracking table + const hasRestoreHistoryTable = await db.schema.hasTable('restore_history'); + if (!hasRestoreHistoryTable) { + await db.schema.createTable('restore_history', (table) => { + table.increments('id').primary(); + table.datetime('started_at').notNullable(); + table.datetime('completed_at'); + table.string('status').defaultTo('running'); // running, completed, failed, partial + table.string('restore_type'); // database, files, full + table.string('backup_id'); // Reference to the backup that was restored + table.string('backup_app_version'); // Version of app that created the backup + table.string('restore_app_version'); // Version of app performing the restore + table.string('backup_node_version'); // Node version that created the backup + table.string('restore_node_version'); // Node version performing the restore + table.string('backup_schema_version'); // Schema version in the backup + table.string('restore_schema_version'); // Current schema version + table.json('version_compatibility'); // Compatibility check results + table.json('restore_options'); // Options used during restore + table.json('statistics'); // Restore statistics + table.text('warnings'); // Any warnings during restore + table.text('error_message'); // Error details if failed + table.string('restored_by'); // User who initiated the restore + table.index(['started_at'], 'idx_restore_started'); + table.index(['backup_id'], 'idx_restore_backup_id'); + }); + console.log('Created restore_history table'); + } + + // Add version compatibility settings + const versionSettings = [ + { + setting_key: 'backup_require_version_match', + setting_value: JSON.stringify(false), // If true, exact version match required for restore + setting_type: 'backup' + }, + { + setting_key: 'backup_allow_minor_version_mismatch', + setting_value: JSON.stringify(true), // Allow restoring from same major version + setting_type: 'backup' + }, + { + setting_key: 'backup_warn_on_version_mismatch', + setting_value: JSON.stringify(true), // Show warning when versions don't match + setting_type: 'backup' + }, + { + setting_key: 'backup_check_schema_compatibility', + setting_value: JSON.stringify(true), // Check if migrations are compatible + setting_type: 'backup' + } + ]; + + // Insert version settings if they don't exist + for (const setting of versionSettings) { + const exists = await db('app_settings') + .where('setting_key', setting.setting_key) + .first(); + + if (!exists) { + await db('app_settings').insert(setting); + } + } + + console.log('Version tracking for backups added successfully'); +} + +async function down() { + // Remove version columns from database_backup_runs + const hasDatabaseBackupRunsTable = await db.schema.hasTable('database_backup_runs'); + if (hasDatabaseBackupRunsTable) { + await db.schema.alterTable('database_backup_runs', (table) => { + table.dropColumn('app_version'); + table.dropColumn('node_version'); + table.dropColumn('db_schema_version'); + table.dropColumn('environment_info'); + }); + } + + // Remove version columns from backup_runs + const hasBackupRunsTable = await db.schema.hasTable('backup_runs'); + if (hasBackupRunsTable) { + await db.schema.alterTable('backup_runs', (table) => { + table.dropColumn('app_version'); + table.dropColumn('node_version'); + table.dropColumn('db_schema_version'); + table.dropColumn('manifest_info'); + }); + } + + // Drop restore_history table + await db.schema.dropTableIfExists('restore_history'); + + // Remove version settings + await db('app_settings') + .whereIn('setting_key', [ + 'backup_require_version_match', + 'backup_allow_minor_version_mismatch', + 'backup_warn_on_version_mismatch', + 'backup_check_schema_compatibility' + ]) + .delete(); +} + +module.exports = { up, down }; \ No newline at end of file diff --git a/backend/migrations/035_enhance_backup_system.js b/backend/migrations/035_enhance_backup_system.js new file mode 100644 index 0000000..5ab4080 --- /dev/null +++ b/backend/migrations/035_enhance_backup_system.js @@ -0,0 +1,221 @@ +const { db } = require('../src/database/db'); + +async function up() { + console.log('Enhancing backup system...'); + + // Add new settings to app_settings table if they don't exist + const backupSettings = [ + { + setting_key: 'backup_s3_force_path_style', + setting_value: JSON.stringify(false), + setting_type: 'backup' + }, + { + setting_key: 'backup_s3_ssl_enabled', + setting_value: JSON.stringify(true), + setting_type: 'backup' + }, + { + setting_key: 'backup_s3_prefix', + setting_value: JSON.stringify(''), + setting_type: 'backup' + }, + { + setting_key: 'backup_incremental', + setting_value: JSON.stringify(false), + setting_type: 'backup' + }, + { + setting_key: 'backup_include_database', + setting_value: JSON.stringify(true), + setting_type: 'backup' + }, + { + setting_key: 'backup_encryption_enabled', + setting_value: JSON.stringify(false), + setting_type: 'backup' + }, + { + setting_key: 'backup_database_schedule', + setting_value: JSON.stringify(''), + setting_type: 'backup' + } + ]; + + // Insert 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); + } + } + + // Check and add columns to backup_runs table + const hasBackupRunsTable = await db.schema.hasTable('backup_runs'); + if (hasBackupRunsTable) { + // Check for existing columns before adding + const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path'); + const hasManifestId = await db.schema.hasColumn('backup_runs', 'manifest_id'); + const hasManifestFormat = await db.schema.hasColumn('backup_runs', 'manifest_format'); + const hasParentBackupId = await db.schema.hasColumn('backup_runs', 'parent_backup_id'); + const hasBackupMode = await db.schema.hasColumn('backup_runs', 'backup_mode'); + + if (!hasManifestPath || !hasManifestId || !hasManifestFormat || !hasParentBackupId || !hasBackupMode) { + await db.schema.alterTable('backup_runs', (table) => { + if (!hasManifestPath) { + table.string('manifest_path', 500).comment('Path to backup manifest file'); + } + if (!hasManifestId) { + table.uuid('manifest_id').comment('Unique identifier for the manifest'); + } + if (!hasManifestFormat) { + table.enum('manifest_format', ['json', 'yaml']).comment('Format of the manifest file'); + } + if (!hasParentBackupId) { + table.integer('parent_backup_id').unsigned().references('id').inTable('backup_runs').onDelete('SET NULL').comment('Parent backup for incremental backups'); + } + if (!hasBackupMode) { + table.enum('backup_mode', ['full', 'incremental', 'database']).defaultTo('full').comment('Type of backup performed'); + } + }); + } + + // Add indexes if they don't exist + try { + await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_mode_status ON backup_runs(backup_mode, status)'); + await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_parent ON backup_runs(parent_backup_id)'); + await db.raw('CREATE INDEX IF NOT EXISTS idx_backup_runs_created_mode ON backup_runs(created_at, backup_mode)'); + } catch (error) { + console.log('Note: Some indexes may already exist, continuing...'); + } + } + + // Create backup_manifest table if it doesn't exist + const hasManifestTable = await db.schema.hasTable('backup_manifest'); + if (!hasManifestTable) { + await db.schema.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 + try { + await db.raw(` + CREATE INDEX IF NOT EXISTS idx_backup_runs_recent_successful + ON backup_runs(created_at DESC) + WHERE status = 'completed' AND backup_mode = 'full'; + `); + await db.raw(` + CREATE INDEX IF NOT EXISTS idx_backup_runs_incremental_chain + ON backup_runs(parent_backup_id, created_at) + WHERE backup_mode = 'incremental'; + `); + } catch (error) { + console.log('Note: Some composite indexes may already exist, continuing...'); + } + + console.log('Backup system enhancements completed'); +} + +async function down() { + // Drop indexes first + try { + await db.raw('DROP INDEX IF EXISTS idx_backup_runs_incremental_chain;'); + await db.raw('DROP INDEX IF EXISTS idx_backup_runs_recent_successful;'); + } catch (error) { + // Ignore errors if indexes don't exist + } + + // Drop backup_manifest table + await db.schema.dropTableIfExists('backup_manifest'); + + // Remove columns from backup_runs if they exist + const hasBackupRunsTable = await db.schema.hasTable('backup_runs'); + if (hasBackupRunsTable) { + const hasBackupMode = await db.schema.hasColumn('backup_runs', 'backup_mode'); + const hasParentBackupId = await db.schema.hasColumn('backup_runs', 'parent_backup_id'); + const hasManifestFormat = await db.schema.hasColumn('backup_runs', 'manifest_format'); + const hasManifestId = await db.schema.hasColumn('backup_runs', 'manifest_id'); + const hasManifestPath = await db.schema.hasColumn('backup_runs', 'manifest_path'); + + if (hasBackupMode || hasParentBackupId || hasManifestFormat || hasManifestId || hasManifestPath) { + await db.schema.alterTable('backup_runs', (table) => { + if (hasBackupMode) table.dropColumn('backup_mode'); + if (hasParentBackupId) table.dropColumn('parent_backup_id'); + if (hasManifestFormat) table.dropColumn('manifest_format'); + if (hasManifestId) table.dropColumn('manifest_id'); + if (hasManifestPath) table.dropColumn('manifest_path'); + }); + } + + // Drop indexes + try { + await db.raw('DROP INDEX IF EXISTS idx_backup_runs_mode_status'); + await db.raw('DROP INDEX IF EXISTS idx_backup_runs_parent'); + await db.raw('DROP INDEX IF EXISTS idx_backup_runs_created_mode'); + } catch (error) { + // Ignore errors if indexes don't exist + } + } + + // Remove settings + await db('app_settings') + .whereIn('setting_key', [ + 'backup_s3_force_path_style', + 'backup_s3_ssl_enabled', + 'backup_s3_prefix', + 'backup_incremental', + 'backup_include_database', + 'backup_encryption_enabled', + 'backup_database_schedule' + ]) + .delete(); +} + +module.exports = { up, down }; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index b7c45eb..71b8d3d 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,38 +1,43 @@ { "name": "picpeak-backend", - "version": "1.0.64", + "version": "1.0.93", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-backend", - "version": "1.0.64", + "version": "1.0.93", "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", - "bcrypt": "^5.1.0", - "chokidar": "^3.5.3", + "bcrypt": "6.0.0", + "chokidar": "4.0.3", "cors": "^2.8.5", "dotenv": "^16.0.3", "express": "^4.18.2", "express-rate-limit": "^6.7.0", "express-validator": "^7.0.1", - "form-data": "^4.0.3", + "form-data": "^4.0.4", "handlebars": "^4.7.8", "helmet": "^7.0.0", - "i18next": "^25.3.1", + "i18next": "25.3.2", "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", - "multer": "^2.0.1", + "mime-types": "^3.0.1", + "multer": "^2.0.2", "node-cron": "^3.0.2", - "nodemailer": "^6.9.1", + "nodemailer": "7.0.5", "pg": "^8.16.3", "react-i18next": "^15.6.0", - "sharp": "^0.32.0", + "sharp": "0.34.3", "sqlite3": "^5.1.6", "uuid": "^11.1.0", "winston": "^3.8.2", @@ -59,6 +64,942 @@ "node": ">=6.0.0" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/crc32c": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32c/-/crc32c-5.2.0.tgz", + "integrity": "sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha1-browser/-/sha1-browser-5.2.0.tgz", + "integrity": "sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha1-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-browser/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-crypto/util/node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@aws-sdk/client-s3": { + "version": "3.850.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.850.0.tgz", + "integrity": "sha512-tX5bUfqiLOh6jtAlaiAuOUKFYh8KDG9k9zFLUdgGplC5TP47AYTreUEg+deCTHo4DD3YCvrLuyZ8tIDgKu7neQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.846.0", + "@aws-sdk/credential-provider-node": "3.848.0", + "@aws-sdk/middleware-bucket-endpoint": "3.840.0", + "@aws-sdk/middleware-expect-continue": "3.840.0", + "@aws-sdk/middleware-flexible-checksums": "3.846.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-location-constraint": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-sdk-s3": "3.846.0", + "@aws-sdk/middleware-ssec": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.848.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/signature-v4-multi-region": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.848.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.0", + "@smithy/eventstream-serde-browser": "^4.0.4", + "@smithy/eventstream-serde-config-resolver": "^4.1.2", + "@smithy/eventstream-serde-node": "^4.0.4", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-blob-browser": "^4.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/hash-stream-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/md5-js": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.15", + "@smithy/middleware-retry": "^4.1.16", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.23", + "@smithy/util-defaults-mode-node": "^4.0.23", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-stream": "^4.2.3", + "@smithy/util-utf8": "^4.0.0", + "@smithy/util-waiter": "^4.0.6", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.848.0.tgz", + "integrity": "sha512-mD+gOwoeZQvbecVLGoCmY6pS7kg02BHesbtIxUj+PeBqYoZV5uLvjUOmuGfw1SfoSobKvS11urxC9S7zxU/Maw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.846.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.848.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.848.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.0", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.15", + "@smithy/middleware-retry": "^4.1.16", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.23", + "@smithy/util-defaults-mode-node": "^4.0.23", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.846.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.846.0.tgz", + "integrity": "sha512-7CX0pM906r4WSS68fCTNMTtBCSkTtf3Wggssmx13gD40gcWEZXsU00KzPp1bYheNRyPlAq3rE22xt4wLPXbuxA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/core": "^3.7.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-utf8": "^4.0.0", + "fast-xml-parser": "5.2.5", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.846.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.846.0.tgz", + "integrity": "sha512-QuCQZET9enja7AWVISY+mpFrEIeHzvkx/JEEbHYzHhUkxcnC2Kq2c0bB7hDihGD0AZd3Xsm653hk1O97qu69zg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.846.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.846.0.tgz", + "integrity": "sha512-Jh1iKUuepdmtreMYozV2ePsPcOF5W9p3U4tWhi3v6nDvz0GsBjzjAROW+BW8XMz9vAD3I9R+8VC3/aq63p5nlw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.848.0.tgz", + "integrity": "sha512-r6KWOG+En2xujuMhgZu7dzOZV3/M5U/5+PXrG8dLQ3rdPRB3vgp5tc56KMqLwm/EXKRzAOSuw/UE4HfNOAB8Hw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/credential-provider-env": "3.846.0", + "@aws-sdk/credential-provider-http": "3.846.0", + "@aws-sdk/credential-provider-process": "3.846.0", + "@aws-sdk/credential-provider-sso": "3.848.0", + "@aws-sdk/credential-provider-web-identity": "3.848.0", + "@aws-sdk/nested-clients": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.848.0.tgz", + "integrity": "sha512-AblNesOqdzrfyASBCo1xW3uweiSro4Kft9/htdxLeCVU1KVOnFWA5P937MNahViRmIQm2sPBCqL8ZG0u9lnh5g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.846.0", + "@aws-sdk/credential-provider-http": "3.846.0", + "@aws-sdk/credential-provider-ini": "3.848.0", + "@aws-sdk/credential-provider-process": "3.846.0", + "@aws-sdk/credential-provider-sso": "3.848.0", + "@aws-sdk/credential-provider-web-identity": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.846.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.846.0.tgz", + "integrity": "sha512-mEpwDYarJSH+CIXnnHN0QOe0MXI+HuPStD6gsv3z/7Q6ESl8KRWon3weFZCDnqpiJMUVavlDR0PPlAFg2MQoPg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.848.0.tgz", + "integrity": "sha512-pozlDXOwJZL0e7w+dqXLgzVDB7oCx4WvtY0sk6l4i07uFliWF/exupb6pIehFWvTUcOvn5aFTTqcQaEzAD5Wsg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.848.0", + "@aws-sdk/core": "3.846.0", + "@aws-sdk/token-providers": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.848.0.tgz", + "integrity": "sha512-D1fRpwPxtVDhcSc/D71exa2gYweV+ocp4D3brF0PgFd//JR3XahZ9W24rVnTQwYEcK9auiBZB89Ltv+WbWN8qw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/nested-clients": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/lib-storage": { + "version": "3.850.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.850.0.tgz", + "integrity": "sha512-DKG8mKeUMLRboyqwhKiV9QOiKXN00OYLnGsT21mhlaF1Uc7OZ6Vm+Olw4YrbYSBuDup0rMWtVaWudJ49I+ZCHA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.15", + "@smithy/smithy-client": "^4.4.7", + "buffer": "5.6.0", + "events": "3.3.0", + "stream-browserify": "3.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@aws-sdk/client-s3": "^3.850.0" + } + }, + "node_modules/@aws-sdk/lib-storage/node_modules/buffer": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", + "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==", + "license": "MIT", + "dependencies": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4" + } + }, + "node_modules/@aws-sdk/middleware-bucket-endpoint": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-bucket-endpoint/-/middleware-bucket-endpoint-3.840.0.tgz", + "integrity": "sha512-+gkQNtPwcSMmlwBHFd4saVVS11In6ID1HczNzpM3MXKXRBfSlbZJbCt6wN//AZ8HMklZEik4tcEOG0qa9UY8SQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-arn-parser": "3.804.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-expect-continue": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-expect-continue/-/middleware-expect-continue-3.840.0.tgz", + "integrity": "sha512-iJg2r6FKsKKvdiU4oCOuCf7Ro/YE0Q2BT/QyEZN3/Rt8Nr4SAZiQOlcBXOCpGvuIKOEAhvDOUnW3aDHL01PdVw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-flexible-checksums": { + "version": "3.846.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-flexible-checksums/-/middleware-flexible-checksums-3.846.0.tgz", + "integrity": "sha512-CdkeVfkwt3+bDLhmOwBxvkUf6oY9iUhvosaUnqkoPsOqIiUEN54yTGOnO8A0wLz6mMsZ6aBlfFrQhFnxt3c+yw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@aws-crypto/crc32c": "5.2.0", + "@aws-crypto/util": "5.2.0", + "@aws-sdk/core": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@smithy/is-array-buffer": "^4.0.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-stream": "^4.2.3", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-host-header": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.840.0.tgz", + "integrity": "sha512-ub+hXJAbAje94+Ya6c6eL7sYujoE8D4Bumu1NUI8TXjUhVVn0HzVWQjpRLshdLsUp1AW7XyeJaxyajRaJQ8+Xg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-location-constraint": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-location-constraint/-/middleware-location-constraint-3.840.0.tgz", + "integrity": "sha512-KVLD0u0YMF3aQkVF8bdyHAGWSUY6N1Du89htTLgqCcIhSxxAJ9qifrosVZ9jkAzqRW99hcufyt2LylcVU2yoKQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-logger": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.840.0.tgz", + "integrity": "sha512-lSV8FvjpdllpGaRspywss4CtXV8M7NNNH+2/j86vMH+YCOZ6fu2T/TyFd/tHwZ92vDfHctWkRbQxg0bagqwovA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.840.0.tgz", + "integrity": "sha512-Gu7lGDyfddyhIkj1Z1JtrY5NHb5+x/CRiB87GjaSrKxkDaydtX2CU977JIABtt69l9wLbcGDIQ+W0uJ5xPof7g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-sdk-s3": { + "version": "3.846.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-sdk-s3/-/middleware-sdk-s3-3.846.0.tgz", + "integrity": "sha512-jP9x+2Q87J5l8FOP+jlAd7vGLn0cC6G9QGmf386e5OslBPqxXKcl3RjqGLIOKKos2mVItY3ApP5xdXQx7jGTVA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-arn-parser": "3.804.0", + "@smithy/core": "^3.7.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-stream": "^4.2.3", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-ssec": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-ssec/-/middleware-ssec-3.840.0.tgz", + "integrity": "sha512-CBZP9t1QbjDFGOrtnUEHL1oAvmnCUUm7p0aPNbIdSzNtH42TNKjPRN3TuEIJDGjkrqpL3MXyDSmNayDcw/XW7Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.848.0.tgz", + "integrity": "sha512-rjMuqSWJEf169/ByxvBqfdei1iaduAnfolTshsZxwcmLIUtbYrFUmts0HrLQqsAG8feGPpDLHA272oPl+NTCCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@smithy/core": "^3.7.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.848.0.tgz", + "integrity": "sha512-joLsyyo9u61jnZuyYzo1z7kmS7VgWRAkzSGESVzQHfOA1H2PYeUFek6vLT4+c9xMGrX/Z6B0tkRdzfdOPiatLg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.846.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.848.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.848.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.848.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.7.0", + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.15", + "@smithy/middleware-retry": "^4.1.16", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.23", + "@smithy/util-defaults-mode-node": "^4.0.23", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/region-config-resolver": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.840.0.tgz", + "integrity": "sha512-Qjnxd/yDv9KpIMWr90ZDPtRj0v75AqGC92Lm9+oHXZ8p1MjG5JE2CW0HL8JRgK9iKzgKBL7pPQRXI8FkvEVfrA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/s3-request-presigner": { + "version": "3.850.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/s3-request-presigner/-/s3-request-presigner-3.850.0.tgz", + "integrity": "sha512-eFvMUCJXoVTkAxkqHKn125mLMGtNa76+oD3wV97ScXUZuL5liaj+kAN9nSqRiQ5vaCz5gsOeB9t/ba/cTGATjg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/signature-v4-multi-region": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-format-url": "3.840.0", + "@smithy/middleware-endpoint": "^4.1.15", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.7", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.846.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.846.0.tgz", + "integrity": "sha512-ZMfIMxUljqZzPJGOcraC6erwq/z1puNMU35cO1a/WdhB+LdYknMn1lr7SJuH754QwNzzIlZbEgg4hoHw50+DpQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-sdk-s3": "3.846.0", + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.848.0.tgz", + "integrity": "sha512-oNPyM4+Di2Umu0JJRFSxDcKQ35+Chl/rAwD47/bS0cDPI8yrao83mLXLeDqpRPHyQW4sXlP763FZcuAibC0+mg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.846.0", + "@aws-sdk/nested-clients": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.840.0.tgz", + "integrity": "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-arn-parser": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-arn-parser/-/util-arn-parser-3.804.0.tgz", + "integrity": "sha512-wmBJqn1DRXnZu3b4EkE6CWnoWMo1ZMvlfkqU5zPz67xx1GMaXlDCchFvKAXMjk4jn/L1O3tKnoFDNsoLV1kgNQ==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-endpoints": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.848.0.tgz", + "integrity": "sha512-fY/NuFFCq/78liHvRyFKr+aqq1aA/uuVSANjzr5Ym8c+9Z3HRPE9OrExAHoMrZ6zC8tHerQwlsXYYH5XZ7H+ww==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-endpoints": "^3.0.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-format-url": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-format-url/-/util-format-url-3.840.0.tgz", + "integrity": "sha512-VB1PWyI1TQPiPvg4w7tgUGGQER1xxXPNUqfh3baxUSFi1Oh8wHrDnFywkxLm3NMmgDmnLnSZ5Q326qAoyqKLSg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.804.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.804.0.tgz", + "integrity": "sha512-zVoRfpmBVPodYlnMjgVjfGoEZagyRF5IPn3Uo6ZvOZp24chnW/FRstH7ESDHDDRga4z3V+ElUQHKpFDXWyBW5A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.840.0.tgz", + "integrity": "sha512-JdyZM3EhhL4PqwFpttZu1afDpPJCCc3eyZOLi+srpX11LsGj6sThf47TYQN75HT1CarZ7cCdQHGzP2uy3/xHfQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.848.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.848.0.tgz", + "integrity": "sha512-Zz1ft9NiLqbzNj/M0jVNxaoxI2F4tGXN0ZbZIj+KJ+PbJo+w5+Jo6d0UDAtbj3AEd79pjcCaP4OA9NTVzItUdw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.848.0", + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.821.0.tgz", + "integrity": "sha512-DIIotRnefVL6DiaHtO6/21DhJ4JZnnIwdNbpwiAhdt/AVbttcE4yw925gsjur0OGv5BTYXQXU3YnANBYnZjuQA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", @@ -604,6 +1545,16 @@ "kuler": "^2.0.0" } }, + "node_modules/@emnapi/runtime": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.5.tgz", + "integrity": "sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", @@ -727,6 +1678,424 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.3.tgz", + "integrity": "sha512-ryFMfvxxpQRsgZJqBd4wsttYQbCxsJksrv9Lw/v798JcQ8+w84mBWuXwl+TT0WJ/WrYOLaYpwQXi3sA9nTIaIg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.3.tgz", + "integrity": "sha512-yHpJYynROAj12TA6qil58hmPmAwxKKC7reUqtGLzsOHfP7/rniNGTL8tjWX6L3CTV4+5P4ypcS7Pp+7OB+8ihA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.0.tgz", + "integrity": "sha512-sBZmpwmxqwlqG9ueWFXtockhsxefaV6O84BMOrhtg/YqbTaRdqDE7hxraVE3y6gVM4eExmfzW4a8el9ArLeEiQ==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.0.tgz", + "integrity": "sha512-M64XVuL94OgiNHa5/m2YvEQI5q2cl9d/wk0qFTDVXcYzi43lxuiFTftMR1tOnFQovVXNZJ5TURSDK2pNe9Yzqg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.0.tgz", + "integrity": "sha512-mWd2uWvDtL/nvIzThLq3fr2nnGfyr/XMXlq8ZJ9WMR6PXijHlC3ksp0IpuhK6bougvQrchUAfzRLnbsen0Cqvw==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.0.tgz", + "integrity": "sha512-RXwd0CgG+uPRX5YYrkzKyalt2OJYRiJQ8ED/fi1tq9WQW2jsQIn0tqrlR5l5dr/rjqq6AHAxURhj2DVjyQWSOA==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.0.tgz", + "integrity": "sha512-Xod/7KaDDHkYu2phxxfeEPXfVXFKx70EAFZ0qyUdOjCcxbjqyJOEUpDe6RIyaunGxT34Anf9ue/wuWOqBW2WcQ==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.0.tgz", + "integrity": "sha512-eMKfzDxLGT8mnmPJTNMcjfO33fLiTDsrMlUVcp6b96ETbnJmd4uvZxVJSKPQfS+odwfVaGifhsB07J1LynFehw==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.0.tgz", + "integrity": "sha512-ZW3FPWIc7K1sH9E3nxIGB3y3dZkpJlMnkk7z5tu1nSkBoCgw2nSRTFHI5pB/3CQaJM0pdzMF3paf9ckKMSE9Tg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.0.tgz", + "integrity": "sha512-UG+LqQJbf5VJ8NWJ5Z3tdIe/HXjuIdo4JeVNADXBFuG7z9zjoegpzzGIyV5zQKi4zaJjnAd2+g2nna8TZvuW9Q==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.0.tgz", + "integrity": "sha512-SRYOLR7CXPgNze8akZwjoGBoN1ThNZoqpOgfnOxmWsklTGVfJiGJoC/Lod7aNMGA1jSsKWM1+HRX43OP6p9+6Q==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.3.tgz", + "integrity": "sha512-oBK9l+h6KBN0i3dC8rYntLiVfW8D8wH+NPNT3O/WBHeW0OQWCjfWksLUaPidsrDKpJgXp3G3/hkmhptAW0I3+A==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.3.tgz", + "integrity": "sha512-QdrKe3EvQrqwkDrtuTIjI0bu6YEJHTgEeqdzI3uWJOH6G1O8Nl1iEeVYRGdj1h5I21CqxSvQp1Yv7xeU3ZewbA==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.3.tgz", + "integrity": "sha512-GLtbLQMCNC5nxuImPR2+RgrviwKwVql28FWZIW1zWruy6zLgA5/x2ZXk3mxj58X/tszVF69KK0Is83V8YgWhLA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.3.tgz", + "integrity": "sha512-3gahT+A6c4cdc2edhsLHmIOXMb17ltffJlxR0aC2VPZfwKoTGZec6u5GrFgdR7ciJSsHT27BD3TIuGcuRT0KmQ==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.0" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.3.tgz", + "integrity": "sha512-8kYso8d806ypnSq3/Ly0QEw90V5ZoHh10yH0HnrzOCr6DKAPI6QVHvwleqMkVQ0m+fc7EH8ah0BB0QPuWY6zJQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.3.tgz", + "integrity": "sha512-vAjbHDlr4izEiXM1OTggpCcPg9tn4YriK5vAjowJsHwdBIdx0fYRsURkxLG2RLm9gyBq66gwtWI8Gx0/ov+JKQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.3.tgz", + "integrity": "sha512-gCWUn9547K5bwvOn9l5XGAEjVTTRji4aPTqLzGXHvIr6bIDZKNTA34seMPgM0WmSf+RYBH411VavCejp3PkOeQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.0" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.3.tgz", + "integrity": "sha512-+CyRcpagHMGteySaWos8IbnXcHgfDn7pO2fiC2slJxvNq9gDipYBN42/RagzctVRKgxATmfqOSulgZv5e1RdMg==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.4.4" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.3.tgz", + "integrity": "sha512-MjnHPnbqMXNC2UgeLJtX4XqoVHHlZNd+nPt1kRPmj63wURegwBhZlApELdtxM2OIZDRv/DFtLcNhVbd1z8GYXQ==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.3.tgz", + "integrity": "sha512-xuCdhH44WxuXgOM714hn4amodJMZl3OEvf0GVTm0BEyMeA2to+8HEdRPShH0SLYptJY1uBw+SCFP9WVQi1Q/cw==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.3.tgz", + "integrity": "sha512-OWwz05d++TxzLEv4VnsTz5CmZ6mI6S05sfQGEMrNrQcOEERbX46332IvE7pO/EUiw7jUrrS40z/M7kPyjfl04g==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1182,26 +2551,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@mapbox/node-pre-gyp": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", - "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", - "license": "BSD-3-Clause", - "dependencies": { - "detect-libc": "^2.0.0", - "https-proxy-agent": "^5.0.0", - "make-dir": "^3.1.0", - "node-fetch": "^2.6.7", - "nopt": "^5.0.0", - "npmlog": "^5.0.1", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.11" - }, - "bin": { - "node-pre-gyp": "bin/node-pre-gyp" - } - }, "node_modules/@noble/hashes": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", @@ -1350,6 +2699,738 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@smithy/abort-controller": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.4.tgz", + "integrity": "sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader/-/chunked-blob-reader-5.0.0.tgz", + "integrity": "sha512-+sKqDBQqb036hh4NPaUiEkYFkTUGYzRsn3EuFhyfQfMy6oGHEUJDurLP9Ufb5dasr/XiAmPNMr6wa9afjQB+Gw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/chunked-blob-reader-native": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/chunked-blob-reader-native/-/chunked-blob-reader-native-4.0.0.tgz", + "integrity": "sha512-R9wM2yPmfEMsUmlMlIgSzOyICs0x9uu7UTHoccMyt7BWw8shcGM8HqB355+BZCPBcySvbTYMs62EgEQkNxz2ig==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-base64": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/config-resolver": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.1.4.tgz", + "integrity": "sha512-prmU+rDddxHOH0oNcwemL+SwnzcG65sBF2yXRO7aeXIn/xTlq2pX7JLVbkBnVLowHLg4/OL4+jBmv9hVrVGS+w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/core": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.7.1.tgz", + "integrity": "sha512-ExRCsHnXFtBPnM7MkfKBPcBBdHw1h/QS/cbNw4ho95qnyNHvnpmGbR39MIAv9KggTr5qSPxRSEL+hRXlyGyGQw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/middleware-serde": "^4.0.8", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-stream": "^4.2.3", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.0.6.tgz", + "integrity": "sha512-hKMWcANhUiNbCJouYkZ9V3+/Qf9pteR1dnwgdyzR09R4ODEYx8BbUysHwRSyex4rZ9zapddZhLFTnT4ZijR4pw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.0.4.tgz", + "integrity": "sha512-7XoWfZqWb/QoR/rAU4VSi0mWnO2vu9/ltS6JZ5ZSZv0eovLVfDfu0/AX4ub33RsJTOth3TiFWSHS5YdztvFnig==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.3.1", + "@smithy/util-hex-encoding": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.0.4.tgz", + "integrity": "sha512-3fb/9SYaYqbpy/z/H3yIi0bYKyAa89y6xPmIqwr2vQiUT2St+avRt8UKwsWt9fEdEasc5d/V+QjrviRaX1JRFA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.1.2.tgz", + "integrity": "sha512-JGtambizrWP50xHgbzZI04IWU7LdI0nh/wGbqH3sJesYToMi2j/DcoElqyOcqEIG/D4tNyxgRuaqBXWE3zOFhQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.0.4.tgz", + "integrity": "sha512-RD6UwNZ5zISpOWPuhVgRz60GkSIp0dy1fuZmj4RYmqLVRtejFqQ16WmfYDdoSoAjlp1LX+FnZo+/hkdmyyGZ1w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.0.4.tgz", + "integrity": "sha512-UeJpOmLGhq1SLox79QWw/0n2PFX+oPRE1ZyRMxPIaFEfCqWaqpB7BU9C8kpPOGEhLF7AwEqfFbtwNxGy4ReENA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.1.0.tgz", + "integrity": "sha512-mADw7MS0bYe2OGKkHYMaqarOXuDwRbO6ArD91XhHcl2ynjGCFF+hvqf0LyQcYxkA1zaWjefSkU7Ne9mqgApSgQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-blob-browser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-blob-browser/-/hash-blob-browser-4.0.4.tgz", + "integrity": "sha512-WszRiACJiQV3QG6XMV44i5YWlkrlsM5Yxgz4jvsksuu7LDXA6wAtypfPajtNTadzpJy3KyJPoWehYpmZGKUFIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/chunked-blob-reader": "^5.0.0", + "@smithy/chunked-blob-reader-native": "^4.0.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-node": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.0.4.tgz", + "integrity": "sha512-qnbTPUhCVnCgBp4z4BUJUhOEkVwxiEi1cyFM+Zj6o+aY8OFGxUQleKWq8ltgp3dujuhXojIvJWdoqpm6dVO3lQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/hash-stream-node": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/hash-stream-node/-/hash-stream-node-4.0.4.tgz", + "integrity": "sha512-wHo0d8GXyVmpmMh/qOR0R7Y46/G1y6OR8U+bSTB4ppEzRxd1xVAQ9xOE9hOc0bSjhz0ujCPAbfNLkLrpa6cevg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/invalid-dependency": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.0.4.tgz", + "integrity": "sha512-bNYMi7WKTJHu0gn26wg8OscncTt1t2b8KcsZxvOv56XA6cyXtOAAAaNP7+m45xfppXfOatXF3Sb1MNsLUgVLTw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.0.0.tgz", + "integrity": "sha512-saYhF8ZZNoJDTvJBEWgeBccCg+yvp1CX+ed12yORU3NilJScfc6gfch2oVb4QgxZrGUx3/ZJlb+c/dJbyupxlw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/md5-js": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/md5-js/-/md5-js-4.0.4.tgz", + "integrity": "sha512-uGLBVqcOwrLvGh/v/jw423yWHq/ofUGK1W31M2TNspLQbUV1Va0F5kTxtirkoHawODAZcjXTSGi7JwbnPcDPJg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-content-length": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.0.4.tgz", + "integrity": "sha512-F7gDyfI2BB1Kc+4M6rpuOLne5LOcEknH1n6UQB69qv+HucXBR1rkzXBnQTB2q46sFy1PM/zuSJOB532yc8bg3w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-endpoint": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.1.16.tgz", + "integrity": "sha512-plpa50PIGLqzMR2ANKAw2yOW5YKS626KYKqae3atwucbz4Ve4uQ9K9BEZxDLIFmCu7hKLcrq2zmj4a+PfmUV5w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.7.1", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry": { + "version": "4.1.17", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.1.17.tgz", + "integrity": "sha512-gsCimeG6BApj0SBecwa1Be+Z+JOJe46iy3B3m3A8jKJHf7eIihP76Is4LwLrbJ1ygoS7Vg73lfqzejmLOrazUA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/protocol-http": "^5.1.2", + "@smithy/service-error-classification": "^4.0.6", + "@smithy/smithy-client": "^4.4.8", + "@smithy/types": "^4.3.1", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-retry/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@smithy/middleware-serde": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.0.8.tgz", + "integrity": "sha512-iSSl7HJoJaGyMIoNn2B7czghOVwJ9nD7TMvLhMWeSB5vt0TnEYyRRqPJu/TqW76WScaNvYYB8nRoiBHR9S1Ddw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/middleware-stack": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.0.4.tgz", + "integrity": "sha512-kagK5ggDrBUCCzI93ft6DjteNSfY8Ulr83UtySog/h09lTIOAJ/xUSObutanlPT0nhoHAkpmW9V5K8oPyLh+QA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-config-provider": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.1.3.tgz", + "integrity": "sha512-HGHQr2s59qaU1lrVH6MbLlmOBxadtzTsoO4c+bF5asdgVik3I8o7JIOzoeqWc5MjVa+vD36/LWE0iXKpNqooRw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.1.0.tgz", + "integrity": "sha512-vqfSiHz2v8b3TTTrdXi03vNz1KLYYS3bhHCDv36FYDqxT7jvTll1mMnCrkD+gOvgwybuunh/2VmvOMqwBegxEg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/property-provider": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.0.4.tgz", + "integrity": "sha512-qHJ2sSgu4FqF4U/5UUp4DhXNmdTrgmoAai6oQiM+c5RZ/sbDwJ12qxB1M6FnP+Tn/ggkPZf9ccn4jqKSINaquw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/protocol-http": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.1.2.tgz", + "integrity": "sha512-rOG5cNLBXovxIrICSBm95dLqzfvxjEmuZx4KK3hWwPFHGdW3lxY0fZNXfv2zebfRO7sJZ5pKJYHScsqopeIWtQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-builder": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.0.4.tgz", + "integrity": "sha512-SwREZcDnEYoh9tLNgMbpop+UTGq44Hl9tdj3rf+yeLcfH7+J8OXEBaMc2kDxtyRHu8BhSg9ADEx0gFHvpJgU8w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "@smithy/util-uri-escape": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/querystring-parser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.0.4.tgz", + "integrity": "sha512-6yZf53i/qB8gRHH/l2ZwUG5xgkPgQF15/KxH0DdXMDHjesA9MeZje/853ifkSY0x4m5S+dfDZ+c4x439PF0M2w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/service-error-classification": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.0.6.tgz", + "integrity": "sha512-RRoTDL//7xi4tn5FrN2NzH17jbgmnKidUqd4KvquT0954/i6CXXkh1884jBiunq24g9cGtPBEXlU40W6EpNOOg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/shared-ini-file-loader": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.0.4.tgz", + "integrity": "sha512-63X0260LoFBjrHifPDs+nM9tV0VMkOTl4JRMYNuKh/f5PauSjowTfvF3LogfkWdcPoxsA9UjqEOgjeYIbhb7Nw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.1.2.tgz", + "integrity": "sha512-d3+U/VpX7a60seHziWnVZOHuEgJlclufjkS6zhXvxcJgkJq4UWdH5eOBLzHRMx6gXjsdT9h6lfpmLzbrdupHgQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-uri-escape": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/smithy-client": { + "version": "4.4.8", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.4.8.tgz", + "integrity": "sha512-pcW691/lx7V54gE+dDGC26nxz8nrvnvRSCJaIYD6XLPpOInEZeKdV/SpSux+wqeQ4Ine7LJQu8uxMvobTIBK0w==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.7.1", + "@smithy/middleware-endpoint": "^4.1.16", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", + "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/url-parser": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.0.4.tgz", + "integrity": "sha512-eMkc144MuN7B0TDA4U2fKs+BqczVbk3W+qIvcoCY6D1JY3hnAdCuhCZODC+GAeaxj0p6Jroz4+XMUn3PCxQQeQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/querystring-parser": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-base64": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-base64/-/util-base64-4.0.0.tgz", + "integrity": "sha512-CvHfCmO2mchox9kjrtzoHkWHxjHZzaFojLc8quxXY7WAAMAg43nuxwv95tATVgQFNDwd4M9S1qFzj40Ul41Kmg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-browser": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-browser/-/util-body-length-browser-4.0.0.tgz", + "integrity": "sha512-sNi3DL0/k64/LO3A256M+m3CDdG6V7WKWHdAiBBMUN8S3hK3aMPhwnPik2A/a2ONN+9doY9UxaLfgqsIRg69QA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-body-length-node": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-body-length-node/-/util-body-length-node-4.0.0.tgz", + "integrity": "sha512-q0iDP3VsZzqJyje8xJWEJCNIu3lktUGVoSy1KB0UWym2CL1siV3artm+u1DFYTLejpsrdGyCSWBdGNjJzfDPjg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.0.0.tgz", + "integrity": "sha512-9TOQ7781sZvddgO8nxueKi3+yGvkY35kotA0Y6BWRajAv8jjmigQ1sBwz0UX47pQMYXJPahSKEKYFgt+rXdcug==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-config-provider": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-config-provider/-/util-config-provider-4.0.0.tgz", + "integrity": "sha512-L1RBVzLyfE8OXH+1hsJ8p+acNUSirQnWQ6/EgpchV88G6zGBTDPdXiiExei6Z1wR2RxYvxY/XLw6AMNCCt8H3w==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-browser": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.0.24.tgz", + "integrity": "sha512-UkQNgaQ+bidw1MgdgPO1z1k95W/v8Ej/5o/T/Is8PiVUYPspl/ZxV6WO/8DrzZQu5ULnmpB9CDdMSRwgRc21AA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/property-provider": "^4.0.4", + "@smithy/smithy-client": "^4.4.8", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-defaults-mode-node": { + "version": "4.0.24", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.0.24.tgz", + "integrity": "sha512-phvGi/15Z4MpuQibTLOYIumvLdXb+XIJu8TA55voGgboln85jytA3wiD7CkUE8SNcWqkkb+uptZKPiuFouX/7g==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/config-resolver": "^4.1.4", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/smithy-client": "^4.4.8", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-endpoints": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.0.6.tgz", + "integrity": "sha512-YARl3tFL3WgPuLzljRUnrS2ngLiUtkwhQtj8PAL13XZSyUiNLQxwG3fBBq3QXFqGFUXepIN73pINp3y8c2nBmA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-hex-encoding": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-hex-encoding/-/util-hex-encoding-4.0.0.tgz", + "integrity": "sha512-Yk5mLhHtfIgW2W2WQZWSg5kuMZCVbvhFmC7rV4IO2QqnZdbEFPmQnCcGMAX2z/8Qj3B9hYYNjZOhWym+RwhePw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-middleware": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.0.4.tgz", + "integrity": "sha512-9MLKmkBmf4PRb0ONJikCbCwORACcil6gUWojwARCClT7RmLzF04hUR4WdRprIXal7XVyrddadYNfp2eF3nrvtQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-retry": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.0.6.tgz", + "integrity": "sha512-+YekoF2CaSMv6zKrA6iI/N9yva3Gzn4L6n35Luydweu5MMPYpiGZlWqehPHDHyNbnyaYlz/WJyYAZnC+loBDZg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/service-error-classification": "^4.0.6", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-stream": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.2.3.tgz", + "integrity": "sha512-cQn412DWHHFNKrQfbHY8vSFI3nTROY1aIKji9N0tpp8gUABRilr7wdf8fqBbSlXresobM+tQFNk6I+0LXK/YZg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/fetch-http-handler": "^5.1.0", + "@smithy/node-http-handler": "^4.1.0", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-buffer-from": "^4.0.0", + "@smithy/util-hex-encoding": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-uri-escape": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-uri-escape/-/util-uri-escape-4.0.0.tgz", + "integrity": "sha512-77yfbCbQMtgtTylO9itEAdpPXSog3ZxMe09AEhm0dU0NLTalV70ghDZFR+Nfi1C60jnJoh/Re4090/DuZh2Omg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.0.0.tgz", + "integrity": "sha512-b+zebfKCfRdgNJDknHCob3O7FpeYQN6ZG6YLExMcasDHsCXlsXCEuiPZeLnJLpwa5dvPetGlnGCiMHuLwGvFow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-waiter": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.0.6.tgz", + "integrity": "sha512-slcr1wdRbX7NFphXZOxtxRNA7hXAAtJAXJDE/wdoMAos27SIquVCKiSqfB6/28YzQ8FCsB5NKkhdM5gMADbqxg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@tootallnate/once": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", @@ -1465,6 +3546,12 @@ "integrity": "sha512-6WaYesThRMCl19iryMYP7/x2OVgCtbIVflDGFpWnb9irXI3UjYE4AzmYuiUKY1AJstGijoY+MgUszMgRxIYTYw==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "9.0.8", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-9.0.8.tgz", + "integrity": "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==", + "license": "MIT" + }, "node_modules/@types/yargs": { "version": "17.0.33", "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", @@ -1493,7 +3580,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/accepts": { "version": "1.3.8", @@ -1508,6 +3596,27 @@ "node": ">= 0.6" } }, + "node_modules/accepts/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/accepts/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1545,6 +3654,7 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", + "optional": true, "dependencies": { "debug": "4" }, @@ -1629,6 +3739,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -1654,6 +3765,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", @@ -1673,7 +3785,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/archiver": { "version": "5.3.2", @@ -1744,25 +3857,10 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/are-we-there-yet": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", - "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-flatten": { @@ -1801,12 +3899,6 @@ "proxy-from-env": "^1.1.0" } }, - "node_modules/b4a": { - "version": "1.6.7", - "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.6.7.tgz", - "integrity": "sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==", - "license": "Apache-2.0" - }, "node_modules/babel-jest": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", @@ -1939,78 +4031,6 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, - "node_modules/bare-events": { - "version": "2.5.4", - "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.5.4.tgz", - "integrity": "sha512-+gFfDkR8pj4/TrWCGUGWmJIkBwuxPS5F+a5yWjOHQt2hHvNZd5YLzadjmDUtFmMM4y429bnKLa8bYBMHcYdnQA==", - "license": "Apache-2.0", - "optional": true - }, - "node_modules/bare-fs": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/bare-fs/-/bare-fs-4.1.6.tgz", - "integrity": "sha512-25RsLF33BqooOEFNdMcEhMpJy8EoR88zSMrnOQOaM3USnOK2VmaJ1uaQEwPA6AQjrv1lXChScosN6CzbwbO9OQ==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-events": "^2.5.4", - "bare-path": "^3.0.0", - "bare-stream": "^2.6.4" - }, - "engines": { - "bare": ">=1.16.0" - }, - "peerDependencies": { - "bare-buffer": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - } - } - }, - "node_modules/bare-os": { - "version": "3.6.1", - "resolved": "https://registry.npmjs.org/bare-os/-/bare-os-3.6.1.tgz", - "integrity": "sha512-uaIjxokhFidJP+bmmvKSgiMzj2sV5GPHaZVAIktcxcpCyBFFWO+YlikVAdhmUo2vYFvFhOXIAlldqV29L8126g==", - "license": "Apache-2.0", - "optional": true, - "engines": { - "bare": ">=1.14.0" - } - }, - "node_modules/bare-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz", - "integrity": "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "bare-os": "^3.0.1" - } - }, - "node_modules/bare-stream": { - "version": "2.6.5", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.6.5.tgz", - "integrity": "sha512-jSmxKJNJmHySi6hC42zlZnq00rga4jjxcgNZjY9N5WlOe/iOoGRtdwGsHzQv2RlH2KOYMwGUXhf2zXd32BA9RA==", - "license": "Apache-2.0", - "optional": true, - "dependencies": { - "streamx": "^2.21.0" - }, - "peerDependencies": { - "bare-buffer": "*", - "bare-events": "*" - }, - "peerDependenciesMeta": { - "bare-buffer": { - "optional": true - }, - "bare-events": { - "optional": true - } - } - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -2032,23 +4052,24 @@ "license": "MIT" }, "node_modules/bcrypt": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", - "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz", + "integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==", "hasInstallScript": true, "license": "MIT", "dependencies": { - "@mapbox/node-pre-gyp": "^1.0.11", - "node-addon-api": "^5.0.0" + "node-addon-api": "^8.3.0", + "node-gyp-build": "^4.8.4" }, "engines": { - "node": ">= 10.0.0" + "node": ">= 18" } }, "node_modules/binary-extensions": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2116,6 +4137,12 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/bowser": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz", + "integrity": "sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2130,6 +4157,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -2407,27 +4435,18 @@ } }, "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" } }, "node_modules/chownr": { @@ -2551,6 +4570,7 @@ "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", "license": "ISC", + "optional": true, "bin": { "color-support": "bin.js" } @@ -2667,7 +4687,8 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/content-disposition": { "version": "0.5.4", @@ -2813,6 +4834,7 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "devOptional": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2895,7 +4917,8 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "license": "MIT" + "license": "MIT", + "optional": true }, "node_modules/depd": { "version": "2.0.0", @@ -3034,6 +5057,7 @@ "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "devOptional": true, "license": "MIT" }, "node_modules/enabled": { @@ -3379,6 +5403,15 @@ "node": ">= 0.6" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -3531,12 +5564,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-fifo": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", - "integrity": "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==", - "license": "MIT" - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -3558,6 +5585,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-xml-parser": { + "version": "5.2.5", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz", + "integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "dependencies": { + "strnum": "^2.1.0" + }, + "bin": { + "fxparser": "src/cli/cli.js" + } + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -3607,6 +5652,7 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -3714,9 +5760,9 @@ } }, "node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -3729,6 +5775,27 @@ "node": ">= 6" } }, + "node_modules/form-data/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/form-data/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/formidable": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/formidable/-/formidable-2.1.5.tgz", @@ -3791,6 +5858,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3810,27 +5878,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", - "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.2", - "console-control-strings": "^1.0.0", - "has-unicode": "^2.0.1", - "object-assign": "^4.1.1", - "signal-exit": "^3.0.0", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.2" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -3947,6 +5994,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "license": "ISC", "dependencies": { "is-glob": "^4.0.1" @@ -4058,7 +6106,8 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/hasown": { "version": "2.0.2", @@ -4140,6 +6189,7 @@ "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", + "optional": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -4169,9 +6219,9 @@ } }, "node_modules/i18next": { - "version": "25.3.1", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz", - "integrity": "sha512-S4CPAx8LfMOnURnnJa8jFWvur+UX/LWcl6+61p9VV7SK2m0445JeBJ6tLD0D5SR0H29G4PYfWkEhivKG5p4RDg==", + "version": "25.3.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.2.tgz", + "integrity": "sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA==", "funding": [ { "type": "individual", @@ -4396,6 +6446,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, "license": "MIT", "dependencies": { "binary-extensions": "^2.0.0" @@ -4423,6 +6474,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -4432,6 +6484,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=8" @@ -4451,6 +6504,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -4470,6 +6524,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -5204,7 +7259,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -5621,30 +7675,6 @@ "yallist": "^3.0.2" } }, - "node_modules/make-dir": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", - "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", - "license": "MIT", - "dependencies": { - "semver": "^6.0.0" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, "node_modules/make-fetch-happen": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz", @@ -5773,21 +7803,21 @@ } }, "node_modules/mime-db": { - "version": "1.52.0", - "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", "license": "MIT", "engines": { "node": ">= 0.6" } }, "node_modules/mime-types": { - "version": "2.1.35", - "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.1.tgz", + "integrity": "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==", "license": "MIT", "dependencies": { - "mime-db": "1.52.0" + "mime-db": "^1.54.0" }, "engines": { "node": ">= 0.6" @@ -5968,9 +7998,9 @@ "license": "MIT" }, "node_modules/multer": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz", - "integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", "license": "MIT", "dependencies": { "append-field": "^1.0.0", @@ -6026,10 +8056,13 @@ } }, "node_modules/node-addon-api": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", - "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", - "license": "MIT" + "version": "8.5.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.5.0.tgz", + "integrity": "sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==", + "license": "MIT", + "engines": { + "node": "^18 || ^20 || >= 21" + } }, "node_modules/node-cron": { "version": "3.0.3", @@ -6097,6 +8130,17 @@ "node": ">= 10.12.0" } }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", + "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", + "license": "MIT", + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-gyp/node_modules/are-we-there-yet": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", @@ -6165,9 +8209,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "6.10.1", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", - "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.5.tgz", + "integrity": "sha512-nsrh2lO3j4GkLLXoeEksAMgAOqxOv6QumNRVQTJwKH4nuiww6iC2y7GyANs9kRAxCexg3+lTWM3PZ91iLlVjfg==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -6202,6 +8246,31 @@ "url": "https://opencollective.com/nodemon" } }, + "node_modules/nodemon/node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, "node_modules/nodemon/node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -6212,6 +8281,19 @@ "node": ">=4" } }, + "node_modules/nodemon/node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, "node_modules/nodemon/node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -6230,6 +8312,7 @@ "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", "license": "ISC", + "optional": true, "dependencies": { "abbrev": "1" }, @@ -6262,19 +8345,6 @@ "node": ">=8" } }, - "node_modules/npmlog": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", - "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", - "deprecated": "This package is no longer supported.", - "license": "ISC", - "dependencies": { - "are-we-there-yet": "^2.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^3.0.0", - "set-blocking": "^2.0.0" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -6606,6 +8676,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -7090,15 +9161,16 @@ } }, "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, "engines": { - "node": ">=8.10.0" + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, "node_modules/rechoir": { @@ -7212,6 +9284,7 @@ "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "deprecated": "Rimraf versions prior to v4 are no longer supported", + "devOptional": true, "license": "ISC", "dependencies": { "glob": "^7.1.3" @@ -7361,7 +9434,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/setprototypeof": { "version": "1.2.0", @@ -7370,34 +9444,47 @@ "license": "ISC" }, "node_modules/sharp": { - "version": "0.32.6", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.32.6.tgz", - "integrity": "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==", + "version": "0.34.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.3.tgz", + "integrity": "sha512-eX2IQ6nFohW4DbvHIOLRB3MHFpYqaqvXd3Tp5e/T/dSH83fxaNJQRvDMhASmkNTsNTVF2/OOopzRCt7xokgPfg==", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { "color": "^4.2.3", - "detect-libc": "^2.0.2", - "node-addon-api": "^6.1.0", - "prebuild-install": "^7.1.1", - "semver": "^7.5.4", - "simple-get": "^4.0.1", - "tar-fs": "^3.0.4", - "tunnel-agent": "^0.6.0" + "detect-libc": "^2.0.4", + "semver": "^7.7.2" }, "engines": { - "node": ">=14.15.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.3", + "@img/sharp-darwin-x64": "0.34.3", + "@img/sharp-libvips-darwin-arm64": "1.2.0", + "@img/sharp-libvips-darwin-x64": "1.2.0", + "@img/sharp-libvips-linux-arm": "1.2.0", + "@img/sharp-libvips-linux-arm64": "1.2.0", + "@img/sharp-libvips-linux-ppc64": "1.2.0", + "@img/sharp-libvips-linux-s390x": "1.2.0", + "@img/sharp-libvips-linux-x64": "1.2.0", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.0", + "@img/sharp-libvips-linuxmusl-x64": "1.2.0", + "@img/sharp-linux-arm": "0.34.3", + "@img/sharp-linux-arm64": "0.34.3", + "@img/sharp-linux-ppc64": "0.34.3", + "@img/sharp-linux-s390x": "0.34.3", + "@img/sharp-linux-x64": "0.34.3", + "@img/sharp-linuxmusl-arm64": "0.34.3", + "@img/sharp-linuxmusl-x64": "0.34.3", + "@img/sharp-wasm32": "0.34.3", + "@img/sharp-win32-arm64": "0.34.3", + "@img/sharp-win32-ia32": "0.34.3", + "@img/sharp-win32-x64": "0.34.3" } }, - "node_modules/sharp/node_modules/node-addon-api": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-6.1.0.tgz", - "integrity": "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==", - "license": "MIT" - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -7497,6 +9584,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, "license": "ISC" }, "node_modules/simple-concat": { @@ -7750,6 +9838,16 @@ "node": ">= 0.8" } }, + "node_modules/stream-browserify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz", + "integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==", + "license": "MIT", + "dependencies": { + "inherits": "~2.0.4", + "readable-stream": "^3.5.0" + } + }, "node_modules/streamsearch": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", @@ -7758,19 +9856,6 @@ "node": ">=10.0.0" } }, - "node_modules/streamx": { - "version": "2.22.1", - "resolved": "https://registry.npmjs.org/streamx/-/streamx-2.22.1.tgz", - "integrity": "sha512-znKXEBxfatz2GBNK02kRnCXjV+AA4kjZIUxeWSr3UGirZMJfTE9uiwKHobnbgxWyL/JWro8tTq+vOqAK1/qbSA==", - "license": "MIT", - "dependencies": { - "fast-fifo": "^1.3.2", - "text-decoder": "^1.1.0" - }, - "optionalDependencies": { - "bare-events": "^2.2.0" - } - }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -7798,6 +9883,7 @@ "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "devOptional": true, "license": "MIT", "dependencies": { "emoji-regex": "^8.0.0", @@ -7812,6 +9898,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, "license": "MIT", "dependencies": { "ansi-regex": "^5.0.1" @@ -7853,6 +9940,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strnum": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.1.tgz", + "integrity": "sha512-7ZvoFTiCnGxBtDqJ//Cu6fWtZtc7Y3x+QOirG15wztbdngGSkht27o2pyGWrVy0b4WAy3jbKmnoK6g5VlVNUUw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT" + }, "node_modules/superagent": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/superagent/-/superagent-8.1.2.tgz", @@ -7945,31 +10044,6 @@ "node": ">=10" } }, - "node_modules/tar-fs": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.0.tgz", - "integrity": "sha512-5Mty5y/sOF1YWj1J6GiBodjlDc05CUR8PKXrsnFAiSG0xA+GHeWLovaZPYUDXkH/1iKRf2+M5+OrRgzC7O9b7w==", - "license": "MIT", - "dependencies": { - "pump": "^3.0.0", - "tar-stream": "^3.1.5" - }, - "optionalDependencies": { - "bare-fs": "^4.0.1", - "bare-path": "^3.0.0" - } - }, - "node_modules/tar-fs/node_modules/tar-stream": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.7.tgz", - "integrity": "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==", - "license": "MIT", - "dependencies": { - "b4a": "^1.6.4", - "fast-fifo": "^1.2.0", - "streamx": "^2.15.0" - } - }, "node_modules/tar-stream": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", @@ -8037,15 +10111,6 @@ "node": ">=8" } }, - "node_modules/text-decoder": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.3.tgz", - "integrity": "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA==", - "license": "Apache-2.0", - "dependencies": { - "b4a": "^1.6.4" - } - }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -8079,6 +10144,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -8121,6 +10187,12 @@ "node": ">= 14.0.0" } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, "node_modules/tunnel-agent": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", @@ -8182,6 +10254,27 @@ "node": ">= 0.6" } }, + "node_modules/type-is/node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/type-is/node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/typedarray": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", @@ -8402,6 +10495,7 @@ "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", "license": "ISC", + "optional": true, "dependencies": { "string-width": "^1.0.2 || 2 || 3 || 4" } diff --git a/backend/package.json b/backend/package.json index 1619285..b69d41a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "picpeak-backend", - "version": "1.0.64", + "version": "1.0.93", "description": "Backend for PicPeak event photo sharing platform", "main": "server.js", "scripts": { @@ -10,34 +10,41 @@ "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", - "bcrypt": "^5.1.0", - "chokidar": "^3.5.3", + "bcrypt": "6.0.0", + "chokidar": "4.0.3", "cors": "^2.8.5", "dotenv": "^16.0.3", "express": "^4.18.2", "express-rate-limit": "^6.7.0", "express-validator": "^7.0.1", - "form-data": "^4.0.3", + "form-data": "^4.0.4", "handlebars": "^4.7.8", "helmet": "^7.0.0", - "i18next": "^25.3.1", + "i18next": "25.3.2", "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", - "multer": "^2.0.1", + "mime-types": "^3.0.1", + "multer": "^2.0.2", "node-cron": "^3.0.2", - "nodemailer": "^6.9.1", + "nodemailer": "7.0.5", "pg": "^8.16.3", "react-i18next": "^15.6.0", - "sharp": "^0.32.0", + "sharp": "0.34.3", "sqlite3": "^5.1.6", "uuid": "^11.1.0", "winston": "^3.8.2", diff --git a/backend/scripts/test-backup-integration.js b/backend/scripts/test-backup-integration.js new file mode 100755 index 0000000..289b3fc --- /dev/null +++ b/backend/scripts/test-backup-integration.js @@ -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 S3 endpoint URL (default: http://localhost:9000) + * --access-key S3 access key (default: minioadmin) + * --secret-key S3 secret key (default: minioadmin) + * --bucket S3 bucket name (default: test-backup-) + * --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 }; \ No newline at end of file diff --git a/backend/scripts/test-backup-manifest.js b/backend/scripts/test-backup-manifest.js new file mode 100644 index 0000000..ca9669e --- /dev/null +++ b/backend/scripts/test-backup-manifest.js @@ -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); \ No newline at end of file diff --git a/backend/scripts/test-backup-service.js b/backend/scripts/test-backup-service.js new file mode 100644 index 0000000..9dfc9fe --- /dev/null +++ b/backend/scripts/test-backup-service.js @@ -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(); \ No newline at end of file diff --git a/backend/scripts/test-restore-service.js b/backend/scripts/test-restore-service.js new file mode 100644 index 0000000..e4c994a --- /dev/null +++ b/backend/scripts/test-restore-service.js @@ -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(); \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index 1939007..8f4a937 100644 --- a/backend/server.js +++ b/backend/server.js @@ -20,10 +20,11 @@ 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 { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService'); -const logger = require('./src/utils/logger'); // Import routes const authRoutes = require('./src/routes/auth-enhanced'); @@ -202,6 +203,10 @@ 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/admin/feedback', require('./src/routes/adminFeedback')); +app.use('/api/gallery', require('./src/routes/galleryFeedback')); 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')); @@ -244,6 +249,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'}`); diff --git a/backend/src/database/db.js b/backend/src/database/db.js index 5eacda8..0e1f5bf 100644 --- a/backend/src/database/db.js +++ b/backend/src/database/db.js @@ -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'); @@ -225,4 +252,4 @@ async function logActivity(activityType, metadata = {}, eventId = null, actor = } } -module.exports = { db, initializeDatabase, logActivity }; \ No newline at end of file +module.exports = { db, initializeDatabase, logActivity, withRetry }; \ No newline at end of file diff --git a/backend/src/middleware/feedbackRateLimit.js b/backend/src/middleware/feedbackRateLimit.js new file mode 100644 index 0000000..fc1ae7d --- /dev/null +++ b/backend/src/middleware/feedbackRateLimit.js @@ -0,0 +1,236 @@ +const crypto = require('crypto'); +const { db } = require('../database/db'); +const logger = require('../utils/logger'); + +/** + * Generate a unique identifier for the guest + */ +function generateGuestIdentifier(req) { + const ip = req.ip || req.connection.remoteAddress || 'unknown'; + const userAgent = req.headers['user-agent'] || 'unknown'; + return crypto + .createHash('sha256') + .update(`${ip}:${userAgent}`) + .digest('hex'); +} + +/** + * Get rate limit settings from app_settings + */ +async function getRateLimitSettings() { + try { + const settings = await db('app_settings') + .where('setting_key', 'feedback_rate_limits') + .first(); + + if (settings && settings.setting_value) { + return JSON.parse(settings.setting_value); + } + + // Default settings + return { + rating: { max: 100, window: 3600 }, // 100 ratings per hour + comment: { max: 20, window: 3600 }, // 20 comments per hour + like: { max: 200, window: 3600 }, // 200 likes per hour + favorite: { max: 100, window: 3600 } // 100 favorites per hour + }; + } catch (error) { + logger.error('Error getting rate limit settings:', error); + // Return defaults on error + return { + rating: { max: 100, window: 3600 }, + comment: { max: 20, window: 3600 }, + like: { max: 200, window: 3600 }, + favorite: { max: 100, window: 3600 } + }; + } +} + +/** + * Check if action is rate limited + */ +async function checkRateLimit(identifier, eventId, actionType) { + try { + const settings = await getRateLimitSettings(); + const limit = settings[actionType] || { max: 100, window: 3600 }; + + // Clean old entries (older than window) + const cutoff = new Date(Date.now() - limit.window * 1000); + await db('feedback_rate_limits') + .where('window_start', '<', cutoff) + .delete(); + + // Count recent actions + const recentActions = await db('feedback_rate_limits') + .where({ + identifier, + event_id: eventId, + action_type: actionType + }) + .where('window_start', '>', cutoff) + .sum('action_count as total') + .first(); + + const currentCount = recentActions?.total || 0; + + if (currentCount >= limit.max) { + return { + limited: true, + limit: limit.max, + window: limit.window, + current: currentCount, + resetAt: new Date(Date.now() + limit.window * 1000) + }; + } + + return { + limited: false, + limit: limit.max, + window: limit.window, + current: currentCount, + remaining: limit.max - currentCount + }; + } catch (error) { + logger.error('Error checking rate limit:', error); + // Allow action on error to avoid blocking legitimate users + return { limited: false }; + } +} + +/** + * Record an action for rate limiting + */ +async function recordAction(identifier, eventId, actionType) { + try { + await db('feedback_rate_limits').insert({ + identifier, + event_id: eventId, + action_type: actionType, + action_count: 1, + window_start: new Date() + }); + } catch (error) { + logger.error('Error recording rate limit action:', error); + } +} + +/** + * Middleware factory for feedback rate limiting + */ +function feedbackRateLimit(actionType) { + return async (req, res, next) => { + try { + // Extract event ID from params or body + const eventId = req.params.eventId || req.body?.event_id; + if (!eventId) { + return res.status(400).json({ error: 'Event ID required' }); + } + + // Generate guest identifier + const identifier = generateGuestIdentifier(req); + req.guestIdentifier = identifier; + + // Check rate limit + const rateLimitStatus = await checkRateLimit(identifier, eventId, actionType); + + // Set rate limit headers + res.set({ + 'X-RateLimit-Limit': rateLimitStatus.limit, + 'X-RateLimit-Remaining': rateLimitStatus.remaining || 0, + 'X-RateLimit-Reset': rateLimitStatus.resetAt ? rateLimitStatus.resetAt.toISOString() : new Date().toISOString() + }); + + if (rateLimitStatus.limited) { + logger.warn(`Rate limit exceeded for ${actionType}`, { + identifier: identifier.substring(0, 16) + '...', + eventId, + actionType + }); + + return res.status(429).json({ + error: 'Too many requests', + message: `Rate limit exceeded. Please try again later.`, + retryAfter: rateLimitStatus.window + }); + } + + // Record the action after successful processing + res.on('finish', async () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + await recordAction(identifier, eventId, actionType); + } + }); + + next(); + } catch (error) { + logger.error('Error in rate limit middleware:', error); + // Allow request to proceed on error + next(); + } + }; +} + +/** + * IP-based rate limiting for more strict control + */ +function strictRateLimit(options = {}) { + const { + windowMs = 15 * 60 * 1000, // 15 minutes + max = 100, // limit each IP to 100 requests per windowMs + message = 'Too many requests from this IP, please try again later.', + skipSuccessfulRequests = false + } = options; + + const store = new Map(); + + // Clean up old entries periodically + setInterval(() => { + const now = Date.now(); + for (const [key, data] of store.entries()) { + if (data.resetTime < now) { + store.delete(key); + } + } + }, windowMs); + + return (req, res, next) => { + const ip = req.ip || req.connection.remoteAddress; + const now = Date.now(); + const resetTime = now + windowMs; + + let data = store.get(ip); + if (!data || data.resetTime < now) { + data = { + count: 0, + resetTime + }; + store.set(ip, data); + } + + if (data.count >= max) { + return res.status(429).json({ + error: 'Too many requests', + message, + retryAfter: Math.ceil((data.resetTime - now) / 1000) + }); + } + + if (!skipSuccessfulRequests || res.statusCode >= 400) { + data.count++; + } + + res.setHeader('X-RateLimit-Limit', max); + res.setHeader('X-RateLimit-Remaining', Math.max(0, max - data.count)); + res.setHeader('X-RateLimit-Reset', new Date(data.resetTime).toISOString()); + + next(); + }; +} + +module.exports = { + feedbackRateLimit, + strictRateLimit, + generateGuestIdentifier, + checkRateLimit, + recordAction +}; \ No newline at end of file diff --git a/backend/src/middleware/gallery.js b/backend/src/middleware/gallery.js index 1852629..42cd7d4 100644 --- a/backend/src/middleware/gallery.js +++ b/backend/src/middleware/gallery.js @@ -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' }); diff --git a/backend/src/middleware/sessionTimeout.js b/backend/src/middleware/sessionTimeout.js index a3966ec..def5f03 100644 --- a/backend/src/middleware/sessionTimeout.js +++ b/backend/src/middleware/sessionTimeout.js @@ -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(() => { diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 7bfa3cb..aabba78 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/adminBackup.js b/backend/src/routes/adminBackup.js new file mode 100644 index 0000000..bef0e69 --- /dev/null +++ b/backend/src/routes/adminBackup.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/adminDashboard.js b/backend/src/routes/adminDashboard.js index 3817acf..cf39bcf 100644 --- a/backend/src/routes/adminDashboard.js +++ b/backend/src/routes/adminDashboard.js @@ -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); diff --git a/backend/src/routes/adminDatabaseBackup.js b/backend/src/routes/adminDatabaseBackup.js new file mode 100644 index 0000000..5197005 --- /dev/null +++ b/backend/src/routes/adminDatabaseBackup.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/adminEvents-enhanced.js b/backend/src/routes/adminEvents-enhanced.js index 740dd16..0134ded 100644 --- a/backend/src/routes/adminEvents-enhanced.js +++ b/backend/src/routes/adminEvents-enhanced.js @@ -42,7 +42,7 @@ router.post('/', adminAuth, [ } = req.body; // Validate password strength for gallery - const passwordValidation = validatePasswordInContext(password, 'gallery', { + const passwordValidation = await validatePasswordInContext(password, 'gallery', { eventName: event_name }); diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 4894c60..9a287a3 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -53,7 +53,7 @@ router.post('/', adminAuth, [ } = req.body; // Validate password strength - const passwordValidation = validatePasswordInContext(password, 'gallery', { + const passwordValidation = await validatePasswordInContext(password, 'gallery', { eventName: event_name }); diff --git a/backend/src/routes/adminFeedback.js b/backend/src/routes/adminFeedback.js new file mode 100644 index 0000000..2be68fe --- /dev/null +++ b/backend/src/routes/adminFeedback.js @@ -0,0 +1,411 @@ +const express = require('express'); +const router = express.Router(); +const { adminAuth } = require('../middleware/auth-enhanced-v2'); +const feedbackService = require('../services/feedbackService'); +const feedbackModeration = require('../services/feedbackModeration'); +const { db, logActivity } = require('../database/db'); +const logger = require('../utils/logger'); +const { + validateEventId, + validateFeedbackSettings, + validateWordFilter, + checkValidation +} = require('../utils/feedbackValidation'); + +// Get event feedback settings +router.get('/events/:eventId/feedback-settings', + adminAuth, + validateEventId, + checkValidation, + async (req, res) => { + try { + const { eventId } = req.params; + + // Verify event exists and belongs to admin + const event = await db('events').where('id', eventId).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + const settings = await feedbackService.getEventFeedbackSettings(eventId); + res.json(settings); + } catch (error) { + logger.error('Error getting feedback settings:', error); + res.status(500).json({ error: 'Failed to get feedback settings' }); + } + } +); + +// Update event feedback settings +router.put('/events/:eventId/feedback-settings', + adminAuth, + validateEventId, + validateFeedbackSettings, + checkValidation, + async (req, res) => { + try { + const { eventId } = req.params; + const settings = req.body; + + // Verify event exists + const event = await db('events').where('id', eventId).first(); + if (!event) { + return res.status(404).json({ error: 'Event not found' }); + } + + const updatedSettings = await feedbackService.updateEventFeedbackSettings(eventId, settings); + + await logActivity('feedback_settings_updated', { + event_id: eventId, + settings: updatedSettings + }, eventId, { + type: 'admin', + id: req.user.id, + name: req.user.username + }); + + res.json(updatedSettings); + } catch (error) { + logger.error('Error updating feedback settings:', error); + res.status(500).json({ error: 'Failed to update feedback settings' }); + } + } +); + +// Get feedback for an event (with filters) +router.get('/events/:eventId/feedback', + adminAuth, + validateEventId, + checkValidation, + async (req, res) => { + try { + const { eventId } = req.params; + const { type, status, photoId, page = 1, limit = 50 } = req.query; + + // Build query + let query = db('photo_feedback') + .join('photos', 'photo_feedback.photo_id', 'photos.id') + .where('photo_feedback.event_id', eventId) + .select( + 'photo_feedback.*', + 'photos.filename', + 'photos.path' + ); + + if (type) { + query = query.where('photo_feedback.feedback_type', type); + } + + if (status === 'pending') { + query = query.where('photo_feedback.is_approved', false) + .where('photo_feedback.is_hidden', false); + } else if (status === 'approved') { + query = query.where('photo_feedback.is_approved', true); + } else if (status === 'hidden') { + query = query.where('photo_feedback.is_hidden', true); + } + + if (photoId) { + query = query.where('photo_feedback.photo_id', photoId); + } + + // Pagination + const offset = (page - 1) * limit; + const totalCount = await query.clone().count('photo_feedback.id as count').first(); + + const feedback = await query + .orderBy('photo_feedback.created_at', 'desc') + .limit(limit) + .offset(offset); + + res.json({ + feedback, + pagination: { + page: parseInt(page), + limit: parseInt(limit), + total: totalCount?.count || 0, + pages: Math.ceil((totalCount?.count || 0) / limit) + } + }); + } catch (error) { + logger.error('Error getting feedback:', error); + res.status(500).json({ error: 'Failed to get feedback' }); + } + } +); + +// Moderate feedback (approve/hide/reject) +router.put('/feedback/:feedbackId/:action', + adminAuth, + async (req, res) => { + try { + const { feedbackId, action } = req.params; + + if (!['approve', 'hide', 'reject'].includes(action)) { + return res.status(400).json({ error: 'Invalid action' }); + } + + await feedbackService.moderateFeedback(feedbackId, action, req.user.id); + + res.json({ success: true }); + } catch (error) { + logger.error('Error moderating feedback:', error); + res.status(500).json({ error: 'Failed to moderate feedback' }); + } + } +); + +// Delete feedback +router.delete('/feedback/:feedbackId', + adminAuth, + async (req, res) => { + try { + const { feedbackId } = req.params; + + await feedbackService.deleteFeedback(feedbackId, req.user.id); + + res.json({ success: true }); + } catch (error) { + logger.error('Error deleting feedback:', error); + res.status(500).json({ error: 'Failed to delete feedback' }); + } + } +); + +// Get feedback analytics for an event +router.get('/events/:eventId/feedback-analytics', + adminAuth, + validateEventId, + checkValidation, + async (req, res) => { + try { + const { eventId } = req.params; + + // Get summary statistics + const summaryData = await feedbackService.getEventFeedbackSummary(eventId); + + // Calculate average rating and other summary stats + const avgRatingResult = await db('photo_feedback') + .where('event_id', eventId) + .where('feedback_type', 'rating') + .avg('rating as average_rating') + .first(); + + const pendingModeration = await db('photo_feedback') + .where('event_id', eventId) + .where('feedback_type', 'comment') + .where('is_approved', false) + .where('is_hidden', false) + .count('* as count') + .first(); + + const summary = { + average_rating: parseFloat(avgRatingResult?.average_rating || 0), + total_ratings: summaryData.stats?.total_ratings || 0, + total_likes: summaryData.stats?.total_likes || 0, + total_comments: summaryData.stats?.total_comments || 0, + total_favorites: summaryData.stats?.total_favorites || 0, + pending_moderation: pendingModeration?.count || 0, + total_feedback: (summaryData.stats?.total_ratings || 0) + + (summaryData.stats?.total_likes || 0) + + (summaryData.stats?.total_comments || 0) + + (summaryData.stats?.total_favorites || 0) + }; + + // Get top-rated photos + const topRated = await db('photos') + .where('event_id', eventId) + .where('average_rating', '>', 0) + .orderBy('average_rating', 'desc') + .orderBy('feedback_count', 'desc') + .limit(10) + .select('id', 'filename', 'average_rating', 'feedback_count', 'like_count'); + + // Get most liked photos + const mostLiked = await db('photos') + .where('event_id', eventId) + .where('like_count', '>', 0) + .orderBy('like_count', 'desc') + .limit(10) + .select('id', 'filename', 'like_count', 'average_rating'); + + // Get recent comments + const recentComments = await db('photo_feedback') + .join('photos', 'photo_feedback.photo_id', 'photos.id') + .where('photo_feedback.event_id', eventId) + .where('photo_feedback.feedback_type', 'comment') + .where('photo_feedback.is_approved', true) + .where('photo_feedback.is_hidden', false) + .orderBy('photo_feedback.created_at', 'desc') + .limit(10) + .select( + 'photo_feedback.comment_text', + 'photo_feedback.guest_name', + 'photo_feedback.created_at', + 'photos.filename' + ); + + // Get feedback timeline (last 7 days) + const timeline = await db('photo_feedback') + .where('event_id', eventId) + .where('created_at', '>', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000)) + .select( + db.raw('DATE(created_at) as date'), + db.raw('COUNT(*) as count'), + 'feedback_type' + ) + .groupBy('date', 'feedback_type') + .orderBy('date', 'asc'); + + res.json({ + summary, + topRated, + mostLiked, + recentComments, + timeline + }); + } catch (error) { + logger.error('Error getting feedback analytics:', error); + res.status(500).json({ error: 'Failed to get feedback analytics' }); + } + } +); + +// Export feedback data +router.get('/events/:eventId/feedback/export', + adminAuth, + validateEventId, + checkValidation, + async (req, res) => { + try { + const { eventId } = req.params; + const { format = 'json' } = req.query; + + const feedback = await feedbackService.exportEventFeedback(eventId); + + if (format === 'csv') { + // Convert to CSV + const csv = convertToCSV(feedback); + res.setHeader('Content-Type', 'text/csv'); + res.setHeader('Content-Disposition', `attachment; filename="feedback-${eventId}.csv"`); + res.send(csv); + } else { + res.json(feedback); + } + } catch (error) { + logger.error('Error exporting feedback:', error); + res.status(500).json({ error: 'Failed to export feedback' }); + } + } +); + +// Get pending moderation items (across all events) +router.get('/feedback/pending-moderation', + adminAuth, + async (req, res) => { + try { + const pending = await feedbackService.getPendingModeration(); + res.json(pending); + } catch (error) { + logger.error('Error getting pending moderation:', error); + res.status(500).json({ error: 'Failed to get pending moderation' }); + } + } +); + +// Word filter management +router.get('/feedback/word-filters', + adminAuth, + async (req, res) => { + try { + const filters = await feedbackModeration.getAllWordFilters(); + res.json(filters); + } catch (error) { + logger.error('Error getting word filters:', error); + res.status(500).json({ error: 'Failed to get word filters' }); + } + } +); + +router.post('/feedback/word-filters', + adminAuth, + validateWordFilter, + checkValidation, + async (req, res) => { + try { + const { word, severity = 'moderate' } = req.body; + + await feedbackModeration.addWordFilter(word, severity); + + await logActivity('word_filter_added', { word, severity }, null, { + type: 'admin', + id: req.user.id, + name: req.user.username + }); + + res.json({ success: true }); + } catch (error) { + if (error.message === 'Word filter already exists') { + return res.status(409).json({ error: error.message }); + } + logger.error('Error adding word filter:', error); + res.status(500).json({ error: 'Failed to add word filter' }); + } + } +); + +router.put('/feedback/word-filters/:id', + adminAuth, + async (req, res) => { + try { + const { id } = req.params; + const updates = req.body; + + await feedbackModeration.updateWordFilter(id, updates); + + res.json({ success: true }); + } catch (error) { + logger.error('Error updating word filter:', error); + res.status(500).json({ error: 'Failed to update word filter' }); + } + } +); + +router.delete('/feedback/word-filters/:id', + adminAuth, + async (req, res) => { + try { + const { id } = req.params; + + await feedbackModeration.deleteWordFilter(id); + + res.json({ success: true }); + } catch (error) { + logger.error('Error deleting word filter:', error); + res.status(500).json({ error: 'Failed to delete word filter' }); + } + } +); + +// Helper function to convert JSON to CSV +function convertToCSV(data) { + if (!data || data.length === 0) return ''; + + const headers = Object.keys(data[0]); + const csvHeaders = headers.join(','); + + const csvRows = data.map(row => { + return headers.map(header => { + const value = row[header]; + // Escape quotes and wrap in quotes if contains comma + if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) { + return `"${value.replace(/"/g, '""')}"`; + } + return value || ''; + }).join(','); + }); + + return [csvHeaders, ...csvRows].join('\n'); +} + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/adminPhotos.js b/backend/src/routes/adminPhotos.js index 4dcb953..5336da6 100644 --- a/backend/src/routes/adminPhotos.js +++ b/backend/src/routes/adminPhotos.js @@ -447,9 +447,14 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => { if (photo.thumbnail_path) { const thumbPath = path.join(storagePath, 'events/active', photo.thumbnail_path); try { + // Check if file exists before attempting to delete + await fs.access(thumbPath); await fs.unlink(thumbPath); } catch (error) { - console.error('Error deleting thumbnail:', error); + // Only log if it's not a "file not found" error + if (error.code !== 'ENOENT') { + console.error('Error deleting thumbnail:', error); + } } } @@ -534,9 +539,14 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, async (req, res) => { if (photo.thumbnail_path) { const thumbPath = path.join(storagePath, photo.thumbnail_path); try { + // Check if file exists before attempting to delete + await fs.access(thumbPath); await fs.unlink(thumbPath); } catch (error) { - console.error('Error deleting thumbnail:', error); + // Only log if it's not a "file not found" error + if (error.code !== 'ENOENT') { + console.error('Error deleting thumbnail:', error); + } } } } diff --git a/backend/src/routes/adminRestore.js b/backend/src/routes/adminRestore.js new file mode 100644 index 0000000..063ff58 --- /dev/null +++ b/backend/src/routes/adminRestore.js @@ -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; \ No newline at end of file diff --git a/backend/src/routes/adminSettings.js b/backend/src/routes/adminSettings.js index 746283e..287896a 100644 --- a/backend/src/routes/adminSettings.js +++ b/backend/src/routes/adminSettings.js @@ -496,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 { diff --git a/backend/src/routes/adminSystem.js b/backend/src/routes/adminSystem.js index e56407d..c726ddd 100644 --- a/backend/src/routes/adminSystem.js +++ b/backend/src/routes/adminSystem.js @@ -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'); diff --git a/backend/src/routes/galleryFeedback.js b/backend/src/routes/galleryFeedback.js new file mode 100644 index 0000000..a579a95 --- /dev/null +++ b/backend/src/routes/galleryFeedback.js @@ -0,0 +1,342 @@ +const express = require('express'); +const router = express.Router(); +const { photoAuth } = require('../middleware/photoAuth'); +const { verifyGalleryAccess } = require('../middleware/gallery'); +const { feedbackRateLimit, generateGuestIdentifier } = require('../middleware/feedbackRateLimit'); +const feedbackService = require('../services/feedbackService'); +const feedbackModeration = require('../services/feedbackModeration'); +const { db, logActivity } = require('../database/db'); +const logger = require('../utils/logger'); +const { + validatePhotoId, + validateFeedbackSubmission, + checkValidation, + validateGuestRequirements +} = require('../utils/feedbackValidation'); +const { escapeLikePattern } = require('../utils/sqlSecurity'); + +// Get feedback settings for a gallery +router.get('/:slug/feedback-settings', + verifyGalleryAccess, + async (req, res) => { + try { + const event = req.event; + const settings = await feedbackService.getEventFeedbackSettings(event.id); + + // Only send relevant settings to guests + const guestSettings = { + feedback_enabled: settings.feedback_enabled, + allow_ratings: settings.allow_ratings, + allow_likes: settings.allow_likes, + allow_comments: settings.allow_comments, + allow_favorites: settings.allow_favorites, + require_name_email: settings.require_name_email, + show_feedback_to_guests: settings.show_feedback_to_guests + }; + + res.json(guestSettings); + } catch (error) { + logger.error('Error getting feedback settings:', error); + res.status(500).json({ error: 'Failed to get feedback settings' }); + } + } +); + +// Get feedback for a specific photo +router.get('/:slug/photos/:photoId/feedback', + verifyGalleryAccess, + validatePhotoId, + checkValidation, + async (req, res) => { + try { + const { photoId } = req.params; + const event = req.event; + const guestIdentifier = generateGuestIdentifier(req); + + // Get feedback settings + const settings = await feedbackService.getEventFeedbackSettings(event.id); + + if (!settings.feedback_enabled) { + return res.status(403).json({ error: 'Feedback is not enabled for this event' }); + } + + // Verify photo belongs to event + const photo = await db('photos') + .where({ id: photoId, event_id: event.id }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Get feedback based on settings + const options = { + approved_only: true, + include_hidden: false + }; + + // Include guest's own feedback even if not approved + const feedback = await feedbackService.getPhotoFeedback(photoId, options); + + // Get guest's own feedback separately + const guestFeedback = await feedbackService.getPhotoFeedback(photoId, { + guest_identifier: guestIdentifier + }); + + // Combine and deduplicate + const allFeedback = [...feedback]; + guestFeedback.forEach(gf => { + if (!feedback.find(f => f.id === gf.id)) { + allFeedback.push({ ...gf, is_mine: true }); + } else { + const index = allFeedback.findIndex(f => f.id === gf.id); + allFeedback[index].is_mine = true; + } + }); + + // Filter based on what guests should see + const visibleFeedback = settings.show_feedback_to_guests ? allFeedback : + allFeedback.filter(f => f.is_mine); + + res.json({ + feedback: visibleFeedback, + summary: { + average_rating: photo.average_rating || 0, + total_ratings: await db('photo_feedback') + .where({ photo_id: photoId, feedback_type: 'rating', is_hidden: false }) + .count('id as count') + .first() + .then(r => r.count), + like_count: photo.like_count || 0, + favorite_count: photo.favorite_count || 0, + comment_count: await db('photo_feedback') + .where({ + photo_id: photoId, + feedback_type: 'comment', + is_approved: true, + is_hidden: false + }) + .count('id as count') + .first() + .then(r => r.count) + }, + my_feedback: { + rating: guestFeedback.find(f => f.feedback_type === 'rating')?.rating, + liked: !!guestFeedback.find(f => f.feedback_type === 'like'), + favorited: !!guestFeedback.find(f => f.feedback_type === 'favorite') + } + }); + } catch (error) { + logger.error('Error getting photo feedback:', error); + res.status(500).json({ error: 'Failed to get feedback' }); + } + } +); + +// Submit feedback for a photo +router.post('/:slug/photos/:photoId/feedback', + verifyGalleryAccess, + validatePhotoId, + validateFeedbackSubmission, + checkValidation, + async (req, res) => { + try { + const { photoId } = req.params; + const event = req.event; + const guestIdentifier = generateGuestIdentifier(req); + + // Get feedback settings + const settings = await feedbackService.getEventFeedbackSettings(event.id); + + if (!settings.feedback_enabled) { + return res.status(403).json({ error: 'Feedback is not enabled for this event' }); + } + + // Check if specific feedback type is allowed + const feedbackType = req.body.feedback_type; + const typeAllowed = { + rating: settings.allow_ratings, + like: settings.allow_likes, + comment: settings.allow_comments, + favorite: settings.allow_favorites + }; + + if (!typeAllowed[feedbackType]) { + return res.status(403).json({ error: `${feedbackType} feedback is not enabled` }); + } + + // Verify photo belongs to event + const photo = await db('photos') + .where({ id: photoId, event_id: event.id }) + .first(); + + if (!photo) { + return res.status(404).json({ error: 'Photo not found' }); + } + + // Validate guest requirements + const guestValidation = await validateGuestRequirements(settings, req.body); + if (!guestValidation.valid) { + return res.status(400).json({ + error: 'Guest information required', + errors: guestValidation.errors + }); + } + + // Apply rate limiting based on feedback type + const rateLimitMiddleware = feedbackRateLimit(feedbackType); + await new Promise((resolve, reject) => { + rateLimitMiddleware(req, res, (err) => { + if (err) reject(err); + else resolve(); + }); + }); + + // If we got here and response was sent (rate limited), return + if (res.headersSent) return; + + // Prepare feedback data + const feedbackData = { + feedback_type: feedbackType, + rating: req.body.rating, + comment_text: req.body.comment_text, + guest_name: req.body.guest_name, + guest_email: req.body.guest_email, + ip_address: req.ip || req.connection.remoteAddress, + user_agent: req.headers['user-agent'], + moderate_comments: settings.moderate_comments + }; + + // For comments, check moderation + if (feedbackType === 'comment') { + // Check user reputation + const reputation = await feedbackModeration.checkUserReputation(guestIdentifier, event.id); + + // Moderate the comment + const moderationResult = await feedbackModeration.moderateText(req.body.comment_text); + + if (!moderationResult.approved) { + // Still save but mark as not approved + feedbackData.is_approved = false; + logger.warn('Comment flagged for moderation:', { + reason: moderationResult.reason, + violations: moderationResult.violations + }); + } else if (reputation.autoApprove) { + // Trusted user, auto-approve + feedbackData.is_approved = true; + } else if (settings.moderate_comments) { + // Default moderation setting + feedbackData.is_approved = false; + } + } + + // Submit feedback + const result = await feedbackService.submitFeedback( + photoId, + event.id, + feedbackData, + guestIdentifier + ); + + // Log activity + await logActivity(`guest_feedback_${feedbackType}`, { + photo_id: photoId, + result + }, event.id, { + type: 'guest', + id: guestIdentifier.substring(0, 16), + name: req.body.guest_name || 'Anonymous' + }); + + res.json({ + success: true, + ...result, + message: feedbackType === 'comment' && !feedbackData.is_approved ? + 'Your comment has been submitted for moderation' : undefined + }); + } catch (error) { + logger.error('Error submitting feedback:', error); + res.status(500).json({ error: 'Failed to submit feedback' }); + } + } +); + +// Get feedback summary for entire gallery +router.get('/:slug/feedback-summary', + verifyGalleryAccess, + async (req, res) => { + try { + const event = req.event; + + // Get feedback settings + const settings = await feedbackService.getEventFeedbackSettings(event.id); + + if (!settings.feedback_enabled || !settings.show_feedback_to_guests) { + return res.json({ + enabled: false, + summary: null + }); + } + + const summary = await feedbackService.getEventFeedbackSummary(event.id); + + // Filter data based on what guests should see + const guestSummary = { + stats: summary.stats, + top_rated: summary.photos + .filter(p => p.average_rating > 0) + .slice(0, 5) + .map(p => ({ + id: p.id, + filename: p.filename, + average_rating: p.average_rating, + like_count: p.like_count + })) + }; + + res.json({ + enabled: true, + settings: { + allow_ratings: settings.allow_ratings, + allow_likes: settings.allow_likes, + allow_comments: settings.allow_comments, + allow_favorites: settings.allow_favorites + }, + summary: guestSummary + }); + } catch (error) { + logger.error('Error getting feedback summary:', error); + res.status(500).json({ error: 'Failed to get feedback summary' }); + } + } +); + +// Get user's own feedback for all photos +router.get('/:slug/my-feedback', + verifyGalleryAccess, + async (req, res) => { + try { + const event = req.event; + const guestIdentifier = generateGuestIdentifier(req); + + const myFeedback = await db('photo_feedback') + .join('photos', 'photo_feedback.photo_id', 'photos.id') + .where('photo_feedback.event_id', event.id) + .where('photo_feedback.guest_identifier', guestIdentifier) + .select( + 'photo_feedback.*', + 'photos.filename', + 'photos.path' + ) + .orderBy('photo_feedback.created_at', 'desc'); + + res.json(myFeedback); + } catch (error) { + logger.error('Error getting user feedback:', error); + res.status(500).json({ error: 'Failed to get your feedback' }); + } + } +); + +module.exports = router; \ No newline at end of file diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index d278b74..388268f 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -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 = {}; @@ -39,9 +45,15 @@ router.get('/', async (req, res) => { theme_config: settingsObject.theme_config || null, default_language: settingsObject.general_default_language || 'en', enable_analytics: settingsObject.general_enable_analytics !== false, + general_date_format: settingsObject.general_date_format || 'PPP', 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); diff --git a/backend/src/services/__tests__/databaseBackup.test.js b/backend/src/services/__tests__/databaseBackup.test.js new file mode 100644 index 0000000..be91ab2 --- /dev/null +++ b/backend/src/services/__tests__/databaseBackup.test.js @@ -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); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/services/archiveService.js b/backend/src/services/archiveService.js index 3f6a963..f4e62ea 100644 --- a/backend/src/services/archiveService.js +++ b/backend/src/services/archiveService.js @@ -4,6 +4,7 @@ const path = require('path'); const { db } = require('../database/db'); const { queueEmail } = require('./emailProcessor'); const logger = require('../utils/logger'); +const feedbackService = require('./feedbackService'); const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); const ACTIVE_PATH = () => path.join(getStoragePath(), 'events/active'); @@ -28,6 +29,37 @@ async function archiveEvent(event) { throw err; }); + // Export feedback data before archiving + const feedbackSettings = await feedbackService.getEventFeedbackSettings(event.id); + if (feedbackSettings.feedback_enabled) { + try { + logger.info(`Exporting feedback data for event ${event.slug}`); + const feedbackData = await feedbackService.exportEventFeedback(event.id); + + if (feedbackData && feedbackData.length > 0) { + // Create feedback JSON file + const feedbackJson = JSON.stringify(feedbackData, null, 2); + const feedbackJsonPath = path.join(eventPath, 'feedback_data.json'); + await fs.writeFile(feedbackJsonPath, feedbackJson, 'utf8'); + + // Create feedback CSV file + const feedbackCsv = convertToCSV(feedbackData); + const feedbackCsvPath = path.join(eventPath, 'feedback_data.csv'); + await fs.writeFile(feedbackCsvPath, feedbackCsv, 'utf8'); + + // Create feedback summary + const summary = await feedbackService.getEventFeedbackSummary(event.id); + const summaryPath = path.join(eventPath, 'feedback_summary.json'); + await fs.writeFile(summaryPath, JSON.stringify(summary, null, 2), 'utf8'); + + logger.info(`Feedback data exported: ${feedbackData.length} entries`); + } + } catch (error) { + logger.error(`Error exporting feedback for event ${event.slug}:`, error); + // Continue with archiving even if feedback export fails + } + } + output.on('close', async () => { logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`); @@ -67,4 +99,25 @@ async function archiveEvent(event) { } } +// Helper function to convert JSON to CSV +function convertToCSV(data) { + if (!data || data.length === 0) return ''; + + const headers = Object.keys(data[0]); + const csvHeaders = headers.join(','); + + const csvRows = data.map(row => { + return headers.map(header => { + const value = row[header]; + // Escape quotes and wrap in quotes if contains comma + if (typeof value === 'string' && (value.includes(',') || value.includes('"'))) { + return `"${value.replace(/"/g, '""')}"`; + } + return value || ''; + }).join(','); + }); + + return [csvHeaders, ...csvRows].join('\n'); +} + module.exports = { archiveEvent }; diff --git a/backend/src/services/backupManifest.js b/backend/src/services/backupManifest.js new file mode 100644 index 0000000..0a082f3 --- /dev/null +++ b/backend/src/services/backupManifest.js @@ -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(); \ No newline at end of file diff --git a/backend/src/services/backupService.js b/backend/src/services/backupService.js new file mode 100644 index 0000000..47f85d3 --- /dev/null +++ b/backend/src/services/backupService.js @@ -0,0 +1,1173 @@ +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'); +const S3StorageAdapter = require('./storage/s3Storage'); +const packageJson = require('../../package.json'); + +// Backup job reference +let backupJob = null; +let backupConfig = null; +let isRunning = false; + +// Storage paths +const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + +/** + * Get current database schema version + */ +async function getCurrentSchemaVersion() { + try { + const result = await db('knex_migrations') + .orderBy('id', 'desc') + .first(); + return result ? result.name : 'unknown'; + } catch (error) { + logger.error('Failed to get schema version:', error); + return 'unknown'; + } +} + +/** + * 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) { + // Check if database has changed since backup + const hasChanged = await hasDatabaseChanged(recentDbBackup.completed_at); + + 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) : {}, + hasChanged: hasChanged, + backupTime: recentDbBackup.completed_at + }; + } + + return { + type: process.env.DB_TYPE === 'postgresql' ? 'postgresql' : 'sqlite', + backupFile: null, + size: 0, + checksum: null, + tables: {}, + rowCounts: {}, + hasChanged: true, + backupTime: null + }; + } catch (error) { + logger.error('Failed to get database backup info:', error); + return { + type: 'unknown', + backupFile: null, + size: 0, + checksum: null, + tables: {}, + rowCounts: {}, + hasChanged: true, + backupTime: null + }; + } +} + +/** + * Check if database has changed since a given time + */ +async function hasDatabaseChanged(sinceTime) { + try { + // List of tables that track modifications + const tablesToCheck = [ + 'events', + 'photos', + 'admin_users', + 'app_settings', + 'email_queue', + 'access_logs' + ]; + + for (const table of tablesToCheck) { + try { + // Check for updated_at timestamps + const hasUpdates = await db(table) + .where('updated_at', '>', sinceTime) + .limit(1) + .first(); + + if (hasUpdates) { + logger.debug(`Database table ${table} has changes since ${sinceTime}`); + return true; + } + + // Also check created_at for new records + const hasNewRecords = await db(table) + .where('created_at', '>', sinceTime) + .limit(1) + .first(); + + if (hasNewRecords) { + logger.debug(`Database table ${table} has new records since ${sinceTime}`); + return true; + } + } catch (error) { + // Table might not exist or not have timestamp columns + logger.debug(`Could not check table ${table} for changes:`, error.message); + } + } + + return false; + } catch (error) { + logger.error('Failed to check database changes:', error); + // Assume changed if we can't check + return true; + } +} + +/** + * 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) { + let s3Client = null; + let backedUpCount = 0; + let backedUpSize = 0; + const backedUpFiles = []; + const storagePath = getStoragePath(); + + try { + // Initialize S3 client with configuration + const s3Config = { + bucket: config.backup_s3_bucket, + region: config.backup_s3_region || 'us-east-1', + endpoint: config.backup_s3_endpoint, + accessKeyId: config.backup_s3_access_key, + secretAccessKey: config.backup_s3_secret_key, + forcePathStyle: config.backup_s3_force_path_style || false, + sslEnabled: config.backup_s3_ssl_enabled !== false, // Default true + maxRetries: 3, + retryDelay: 1000 + }; + + // Validate required S3 configuration + if (!s3Config.bucket || !s3Config.accessKeyId || !s3Config.secretAccessKey) { + throw new Error('S3 backup configuration incomplete: bucket, access key, and secret key are required'); + } + + // Create S3 client + s3Client = new S3StorageAdapter(s3Config); + + // Test connection + logger.info('Testing S3 connection...'); + await s3Client.testConnection(); + + // Determine backup prefix based on date and configuration + const now = new Date(); + const datePrefix = `${now.getFullYear()}/${String(now.getMonth() + 1).padStart(2, '0')}/${String(now.getDate()).padStart(2, '0')}`; + const backupId = `backup-${now.getTime()}`; + const s3Prefix = config.backup_s3_prefix ? + path.posix.join(config.backup_s3_prefix, datePrefix, backupId) : + path.posix.join('backups', datePrefix, backupId); + + logger.info(`Starting S3 backup to prefix: ${s3Prefix}`); + + // Process each file + 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 && config.backup_incremental !== false) { + continue; + } + + // Determine S3 key for the file + const s3Key = path.posix.join(s3Prefix, file.relativePath); + + // Upload file to S3 + logger.debug(`Uploading ${file.relativePath} to S3 key: ${s3Key}`); + + let uploadStartTime = Date.now(); + await s3Client.upload(file.path, s3Key, { + metadata: { + 'original-path': file.relativePath, + 'checksum': checksum, + 'backup-id': backupId, + 'backup-time': now.toISOString() + }, + onProgress: (loaded, total) => { + const percentComplete = Math.round((loaded / total) * 100); + if (percentComplete % 25 === 0) { // Log at 25%, 50%, 75%, 100% + logger.debug(`Upload progress for ${file.relativePath}: ${percentComplete}%`); + } + } + }); + + const uploadDuration = Date.now() - uploadStartTime; + logger.debug(`Uploaded ${file.relativePath} in ${uploadDuration}ms`); + + // 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} to S3:`, error); + // Continue with other files even if one fails + } + } + + // Check if database backup should be included + if (config.backup_include_database !== false) { + try { + logger.info('Including database backup in S3 backup...'); + const dbInfo = await getDatabaseBackupInfo(); + + if (dbInfo.backupFile && await fs.stat(dbInfo.backupFile).catch(() => null)) { + // Upload database backup file + const dbFileName = path.basename(dbInfo.backupFile); + const dbS3Key = path.posix.join(s3Prefix, 'database', dbFileName); + + await s3Client.upload(dbInfo.backupFile, dbS3Key, { + metadata: { + 'backup-type': 'database', + 'database-type': dbInfo.type, + 'checksum': dbInfo.checksum, + 'backup-id': backupId, + 'backup-time': now.toISOString() + } + }); + + backedUpCount++; + backedUpSize += dbInfo.size; + logger.info(`Database backup uploaded to S3: ${dbS3Key}`); + } else { + logger.warn('No recent database backup found to include in S3 backup'); + } + } catch (error) { + logger.error('Failed to include database backup in S3:', error); + } + } + + // Create and upload a backup summary file + try { + const summary = { + backupId: backupId, + timestamp: now.toISOString(), + s3Bucket: config.backup_s3_bucket, + s3Prefix: s3Prefix, + filesBackedUp: backedUpCount, + totalSize: backedUpSize, + totalSizeFormatted: formatBytes(backedUpSize), + configuration: { + incremental: config.backup_incremental !== false, + includeArchived: config.backup_include_archived, + includeDatabase: config.backup_include_database !== false, + maxFileSizeMB: config.backup_max_file_size_mb || 5000 + } + }; + + const summaryJson = JSON.stringify(summary, null, 2); + const summaryS3Key = path.posix.join(s3Prefix, 'backup-summary.json'); + + // Create a temporary file for the summary + const tempSummaryPath = path.join(storagePath, `temp-summary-${backupId}.json`); + await fs.writeFile(tempSummaryPath, summaryJson); + + await s3Client.upload(tempSummaryPath, summaryS3Key, { + contentType: 'application/json', + metadata: { + 'backup-id': backupId, + 'backup-type': 'summary' + } + }); + + // Clean up temp file + await fs.unlink(tempSummaryPath).catch(() => {}); + + logger.info(`Backup summary uploaded to S3: ${summaryS3Key}`); + } catch (error) { + logger.error('Failed to upload backup summary:', error); + } + + logger.info(`S3 backup completed: ${backedUpCount} files, ${formatBytes(backedUpSize)} uploaded to ${s3Prefix}`); + + return { + backedUpCount, + backedUpSize, + backedUpFiles, + s3Prefix, + s3Bucket: config.backup_s3_bucket + }; + + } catch (error) { + logger.error('S3 backup failed:', error); + throw new Error(`S3 backup failed: ${error.message}`); + } +} + +/** + * Format bytes to human readable string + */ +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]; +} + +/** + * 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; + } + + // Get current schema version + const schemaVersion = await getCurrentSchemaVersion(); + + // Create backup run record with version info + const [runId] = await db('backup_runs').insert({ + started_at: startTime, + status: 'running', + backup_type: 'scheduled', + app_version: packageJson.version, + node_version: process.version, + db_schema_version: schemaVersion + }); + + 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(); + + // Prepare backup path based on destination type + let backupPath; + if (config.backup_destination_type === 's3') { + backupPath = `s3://${result.s3Bucket}/${result.s3Prefix}`; + } else { + backupPath = config.backup_destination_path || config.backup_destination_type; + } + + let manifest; + const manifestOptions = { + backupType: lastSuccessfulBackup ? 'incremental' : 'full', + backupPath: backupPath, + 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, + // Add S3-specific metadata if applicable + ...(config.backup_destination_type === 's3' ? { + s3_bucket: result.s3Bucket, + s3_prefix: result.s3Prefix, + s3_region: config.backup_s3_region || 'us-east-1', + s3_endpoint: config.backup_s3_endpoint + } : {}) + } + }; + + 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 manifestFileName = `backup-manifest-${manifest.backup.id}.${config.backup_manifest_format || 'json'}`; + + if (config.backup_destination_type === 's3') { + // For S3 backups, save manifest locally first then upload to S3 + const tempManifestDir = path.join(getStoragePath(), 'temp'); + await fs.mkdir(tempManifestDir, { recursive: true }); + + const tempManifestPath = path.join(tempManifestDir, manifestFileName); + await backupManifest.saveManifest(manifest, tempManifestPath, config.backup_manifest_format || 'json'); + + // Upload manifest to S3 + try { + const s3Config = { + bucket: config.backup_s3_bucket, + region: config.backup_s3_region || 'us-east-1', + endpoint: config.backup_s3_endpoint, + accessKeyId: config.backup_s3_access_key, + secretAccessKey: config.backup_s3_secret_key, + forcePathStyle: config.backup_s3_force_path_style || false, + sslEnabled: config.backup_s3_ssl_enabled !== false + }; + + const s3Client = new S3StorageAdapter(s3Config); + const manifestS3Key = path.posix.join(result.s3Prefix, 'manifests', manifestFileName); + + await s3Client.upload(tempManifestPath, manifestS3Key, { + contentType: config.backup_manifest_format === 'xml' ? 'application/xml' : 'application/json', + metadata: { + 'backup-id': manifest.backup.id, + 'backup-type': 'manifest', + 'manifest-version': manifest.version + } + }); + + // Clean up temp file + await fs.unlink(tempManifestPath).catch(() => {}); + + // Store S3 path as manifest path + manifestPath = `s3://${config.backup_s3_bucket}/${manifestS3Key}`; + logger.info(`Backup manifest uploaded to S3: ${manifestPath}`); + + } catch (error) { + logger.error('Failed to upload manifest to S3:', error); + // Keep local path as fallback + manifestPath = tempManifestPath; + } + } else { + // For local/rsync backups, save to configured directory + const manifestDir = config.backup_manifest_path || path.join(config.backup_destination_path || '/backup', 'manifests'); + await fs.mkdir(manifestDir, { recursive: true }); + + 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 with manifest info + 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, + manifest_info: manifestSummary ? JSON.stringify({ + manifest_version: manifestSummary.manifest?.version, + backup_id: manifestSummary.backup?.id, + system_info: manifestSummary.system, + file_count: manifestSummary.files?.count, + database_info: { + type: manifestSummary.database?.type, + schema_version: manifestSummary.database?.schema_version + } + }) : 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'); + } + + let manifest; + + // Check if manifest is stored in S3 + if (run.manifest_path.startsWith('s3://')) { + // Parse S3 path + const s3PathMatch = run.manifest_path.match(/^s3:\/\/([^\/]+)\/(.+)$/); + if (!s3PathMatch) { + throw new Error('Invalid S3 manifest path'); + } + + const [, bucket, key] = s3PathMatch; + + // Get S3 configuration from backup settings + const config = await getBackupConfig(); + if (!config.backup_s3_access_key || !config.backup_s3_secret_key) { + throw new Error('S3 credentials not configured for manifest retrieval'); + } + + // Initialize S3 client + const s3Config = { + bucket: bucket, + region: config.backup_s3_region || 'us-east-1', + endpoint: config.backup_s3_endpoint, + accessKeyId: config.backup_s3_access_key, + secretAccessKey: config.backup_s3_secret_key, + forcePathStyle: config.backup_s3_force_path_style || false, + sslEnabled: config.backup_s3_ssl_enabled !== false + }; + + const s3Client = new S3StorageAdapter(s3Config); + + // Download manifest to temporary location + const tempDir = path.join(getStoragePath(), 'temp'); + await fs.mkdir(tempDir, { recursive: true }); + + const tempManifestPath = path.join(tempDir, `manifest-${backupRunId}.json`); + await s3Client.download(key, tempManifestPath); + + // Load manifest + manifest = await backupManifest.loadManifest(tempManifestPath); + + // Clean up temp file + await fs.unlink(tempManifestPath).catch(() => {}); + + } else { + // Load manifest from local filesystem + 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 { + let manifest; + + // Check if manifest is stored in S3 + if (manifestPath.startsWith('s3://')) { + // Parse S3 path + const s3PathMatch = manifestPath.match(/^s3:\/\/([^\/]+)\/(.+)$/); + if (!s3PathMatch) { + throw new Error('Invalid S3 manifest path'); + } + + const [, bucket, key] = s3PathMatch; + + // Get S3 configuration from backup settings + const config = await getBackupConfig(); + if (!config.backup_s3_access_key || !config.backup_s3_secret_key) { + throw new Error('S3 credentials not configured for manifest validation'); + } + + // Initialize S3 client + const s3Config = { + bucket: bucket, + region: config.backup_s3_region || 'us-east-1', + endpoint: config.backup_s3_endpoint, + accessKeyId: config.backup_s3_access_key, + secretAccessKey: config.backup_s3_secret_key, + forcePathStyle: config.backup_s3_force_path_style || false, + sslEnabled: config.backup_s3_ssl_enabled !== false + }; + + const s3Client = new S3StorageAdapter(s3Config); + + // Download manifest to temporary location + const tempDir = path.join(getStoragePath(), 'temp'); + await fs.mkdir(tempDir, { recursive: true }); + + const tempManifestPath = path.join(tempDir, `validate-manifest-${Date.now()}.json`); + await s3Client.download(key, tempManifestPath); + + // Load manifest + manifest = await backupManifest.loadManifest(tempManifestPath); + + // Clean up temp file + await fs.unlink(tempManifestPath).catch(() => {}); + + } else { + // Load manifest from local filesystem + manifest = await backupManifest.loadManifest(manifestPath); + } + + // Validate the manifest + 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 +}; \ No newline at end of file diff --git a/backend/src/services/backupService.original.js b/backend/src/services/backupService.original.js new file mode 100644 index 0000000..26b2b51 --- /dev/null +++ b/backend/src/services/backupService.original.js @@ -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 +}; \ No newline at end of file diff --git a/backend/src/services/databaseBackup.example.js b/backend/src/services/databaseBackup.example.js new file mode 100644 index 0000000..ed0a7af --- /dev/null +++ b/backend/src/services/databaseBackup.example.js @@ -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 +}; \ No newline at end of file diff --git a/backend/src/services/databaseBackup.js b/backend/src/services/databaseBackup.js new file mode 100644 index 0000000..5252036 --- /dev/null +++ b/backend/src/services/databaseBackup.js @@ -0,0 +1,718 @@ +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'); +const packageJson = require('../../package.json'); + +// 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; + + // Get current schema version + const schemaVersion = await this.getCurrentSchemaVersion(); + + // Create backup run record with version info + const [runId] = await db('database_backup_runs').insert({ + started_at: startTime, + status: 'running', + backup_type: this.dbType, + destination_path: finalFile, + app_version: packageJson.version, + node_version: process.version, + db_schema_version: schemaVersion, + environment_info: JSON.stringify({ + platform: process.platform, + arch: process.arch, + node_env: process.env.NODE_ENV || 'production', + db_type: this.dbType + }) + }); + + 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, + app_version: packageJson.version, + node_version: process.version, + db_schema_version: await this.getCurrentSchemaVersion() + }) + }); + + 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); + } + + /** + * Get current database schema version + */ + async getCurrentSchemaVersion() { + try { + const result = await db('knex_migrations') + .orderBy('id', 'desc') + .first(); + return result ? result.name : 'unknown'; + } catch (error) { + logger.error('Failed to get schema version:', error); + return 'unknown'; + } + } + + /** + * Check version compatibility for restore + */ + async checkVersionCompatibility(backupInfo) { + const currentAppVersion = packageJson.version; + const currentNodeVersion = process.version; + const currentSchemaVersion = await this.getCurrentSchemaVersion(); + + const compatibility = { + compatible: true, + warnings: [], + errors: [] + }; + + // Check app version + if (backupInfo.app_version !== currentAppVersion) { + const backupMajor = backupInfo.app_version?.split('.')[0]; + const currentMajor = currentAppVersion.split('.')[0]; + + if (backupMajor !== currentMajor) { + compatibility.errors.push( + `Major version mismatch: backup v${backupInfo.app_version}, current v${currentAppVersion}` + ); + compatibility.compatible = false; + } else { + compatibility.warnings.push( + `Minor version difference: backup v${backupInfo.app_version}, current v${currentAppVersion}` + ); + } + } + + // Check Node.js version + if (backupInfo.node_version !== currentNodeVersion) { + const backupNodeMajor = backupInfo.node_version?.split('.')[0]; + const currentNodeMajor = currentNodeVersion.split('.')[0]; + + if (backupNodeMajor !== currentNodeMajor) { + compatibility.warnings.push( + `Node.js major version difference: backup ${backupInfo.node_version}, current ${currentNodeVersion}` + ); + } + } + + // Check schema version + if (backupInfo.db_schema_version && backupInfo.db_schema_version !== currentSchemaVersion) { + compatibility.warnings.push( + `Database schema difference: backup migration '${backupInfo.db_schema_version}', current '${currentSchemaVersion}'` + ); + compatibility.warnings.push( + 'You may need to run migrations after restore' + ); + } + + return compatibility; + } + + /** + * Restore from backup (with version checking) + */ + 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 use restore service or 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 +}; \ No newline at end of file diff --git a/backend/src/services/emailService.js b/backend/src/services/emailService.js index 7ec83b5..2368622 100644 --- a/backend/src/services/emailService.js +++ b/backend/src/services/emailService.js @@ -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 }; diff --git a/backend/src/services/feedbackModeration.js b/backend/src/services/feedbackModeration.js new file mode 100644 index 0000000..73da47c --- /dev/null +++ b/backend/src/services/feedbackModeration.js @@ -0,0 +1,312 @@ +const { db } = require('../database/db'); +const logger = require('../utils/logger'); + +class FeedbackModerationService { + constructor() { + this.wordFiltersCache = null; + this.cacheExpiry = null; + this.CACHE_DURATION = 5 * 60 * 1000; // 5 minutes + } + + /** + * Get word filters (with caching) + */ + async getWordFilters() { + try { + // Check cache + if (this.wordFiltersCache && this.cacheExpiry && Date.now() < this.cacheExpiry) { + return this.wordFiltersCache; + } + + // Fetch from database + const filters = await db('feedback_word_filters') + .where('is_active', true) + .select('word', 'severity'); + + // Update cache + this.wordFiltersCache = filters; + this.cacheExpiry = Date.now() + this.CACHE_DURATION; + + return filters; + } catch (error) { + logger.error('Error getting word filters:', error); + return []; + } + } + + /** + * Clear word filters cache + */ + clearCache() { + this.wordFiltersCache = null; + this.cacheExpiry = null; + } + + /** + * Check if text contains inappropriate content + */ + async moderateText(text) { + try { + if (!text || typeof text !== 'string') { + return { approved: true }; + } + + const filters = await this.getWordFilters(); + const violations = []; + const lowerText = text.toLowerCase(); + + for (const filter of filters) { + // Create regex for whole word matching + const regex = new RegExp(`\\b${this.escapeRegex(filter.word.toLowerCase())}\\b`, 'gi'); + if (regex.test(lowerText)) { + violations.push({ + word: filter.word, + severity: filter.severity + }); + } + } + + // Check for severe violations + if (violations.some(v => v.severity === 'severe')) { + return { + approved: false, + reason: 'Content contains prohibited words', + violations: violations.filter(v => v.severity === 'severe') + }; + } + + // Check for moderate violations + if (violations.some(v => v.severity === 'moderate')) { + return { + approved: false, + reason: 'Content requires moderation', + violations + }; + } + + // Check for mild violations (may just flag for review) + if (violations.length > 0) { + return { + approved: true, + flagged: true, + reason: 'Content contains potentially inappropriate words', + violations + }; + } + + // Additional checks + const additionalChecks = this.performAdditionalChecks(text); + if (!additionalChecks.passed) { + return { + approved: false, + reason: additionalChecks.reason + }; + } + + return { approved: true }; + } catch (error) { + logger.error('Error moderating text:', error); + // In case of error, err on the side of caution + return { + approved: false, + reason: 'Moderation system error' + }; + } + } + + /** + * Perform additional content checks + */ + performAdditionalChecks(text) { + // Check for excessive caps + const capsRatio = (text.match(/[A-Z]/g) || []).length / text.length; + if (text.length > 10 && capsRatio > 0.7) { + return { + passed: false, + reason: 'Excessive use of capital letters' + }; + } + + // Check for spam patterns + if (this.detectSpamPatterns(text)) { + return { + passed: false, + reason: 'Content appears to be spam' + }; + } + + // Check for excessive special characters + const specialCharRatio = (text.match(/[!@#$%^&*()]/g) || []).length / text.length; + if (text.length > 10 && specialCharRatio > 0.3) { + return { + passed: false, + reason: 'Excessive use of special characters' + }; + } + + return { passed: true }; + } + + /** + * Detect common spam patterns + */ + detectSpamPatterns(text) { + const spamPatterns = [ + /\b(buy|cheap|discount|offer|sale|deal)\s+(now|today|here)/gi, + /\b(click|visit|check)\s+(here|link|this)/gi, + /\b(viagra|cialis|pills|drugs)\b/gi, + /\b(casino|betting|poker|slots)\b/gi, + /\b(make|earn)\s+\$?\d+/gi, + /https?:\/\/[^\s]+/gi, // URLs (might want to allow in some cases) + /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, // Email addresses + /\b\d{3,}\s?\d{3,}\s?\d{4,}\b/g // Phone numbers + ]; + + return spamPatterns.some(pattern => pattern.test(text)); + } + + /** + * Escape special regex characters + */ + escapeRegex(string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + } + + /** + * Add word filter + */ + async addWordFilter(word, severity = 'moderate') { + try { + await db('feedback_word_filters').insert({ + word: word.toLowerCase(), + severity, + is_active: true, + created_at: new Date() + }); + + this.clearCache(); + logger.info(`Added word filter: ${word} (${severity})`); + + return true; + } catch (error) { + if (error.code === 'SQLITE_CONSTRAINT' || error.code === '23505') { + throw new Error('Word filter already exists'); + } + logger.error('Error adding word filter:', error); + throw error; + } + } + + /** + * Update word filter + */ + async updateWordFilter(id, updates) { + try { + await db('feedback_word_filters') + .where('id', id) + .update(updates); + + this.clearCache(); + return true; + } catch (error) { + logger.error('Error updating word filter:', error); + throw error; + } + } + + /** + * Delete word filter + */ + async deleteWordFilter(id) { + try { + await db('feedback_word_filters') + .where('id', id) + .delete(); + + this.clearCache(); + return true; + } catch (error) { + logger.error('Error deleting word filter:', error); + throw error; + } + } + + /** + * Get all word filters (for admin) + */ + async getAllWordFilters() { + try { + return await db('feedback_word_filters') + .orderBy('severity', 'desc') + .orderBy('word', 'asc'); + } catch (error) { + logger.error('Error getting all word filters:', error); + throw error; + } + } + + /** + * Sanitize text for display (remove but don't reject) + */ + sanitizeText(text) { + // Remove excessive whitespace + text = text.replace(/\s+/g, ' ').trim(); + + // Remove zero-width characters + text = text.replace(/[\u200B-\u200D\uFEFF]/g, ''); + + // Limit consecutive special characters + text = text.replace(/([!?.]){3,}/g, '$1$1'); + + return text; + } + + /** + * Check if user should be rate limited based on previous violations + */ + async checkUserReputation(guestIdentifier, eventId) { + try { + // Count recent violations + const recentViolations = await db('photo_feedback') + .where('guest_identifier', guestIdentifier) + .where('event_id', eventId) + .where('is_hidden', true) + .where('created_at', '>', new Date(Date.now() - 24 * 60 * 60 * 1000)) // Last 24 hours + .count('id as count') + .first(); + + // If user has multiple violations, they might be problematic + if (recentViolations && recentViolations.count > 3) { + return { + trusted: false, + reason: 'Multiple recent violations' + }; + } + + // Check total approved comments + const approvedComments = await db('photo_feedback') + .where('guest_identifier', guestIdentifier) + .where('event_id', eventId) + .where('feedback_type', 'comment') + .where('is_approved', true) + .where('is_hidden', false) + .count('id as count') + .first(); + + // User with many approved comments is trusted + if (approvedComments && approvedComments.count > 10) { + return { + trusted: true, + autoApprove: true + }; + } + + return { trusted: true }; + } catch (error) { + logger.error('Error checking user reputation:', error); + return { trusted: true }; // Default to trusting in case of error + } + } +} + +module.exports = new FeedbackModerationService(); \ No newline at end of file diff --git a/backend/src/services/feedbackService.js b/backend/src/services/feedbackService.js new file mode 100644 index 0000000..8506ae3 --- /dev/null +++ b/backend/src/services/feedbackService.js @@ -0,0 +1,393 @@ +const { db, logActivity } = require('../database/db'); +const logger = require('../utils/logger'); +const { formatBoolean } = require('../utils/dbCompat'); + +class FeedbackService { + /** + * Get feedback settings for an event + */ + async getEventFeedbackSettings(eventId) { + try { + const settings = await db('event_feedback_settings') + .where('event_id', eventId) + .first(); + + if (!settings) { + // Return default settings if none exist + return { + event_id: eventId, + feedback_enabled: false, + allow_ratings: true, + allow_likes: true, + allow_comments: false, + allow_favorites: true, + require_name_email: false, + moderate_comments: true, + show_feedback_to_guests: true + }; + } + + return settings; + } catch (error) { + logger.error('Error getting feedback settings:', error); + throw error; + } + } + + /** + * Update feedback settings for an event + */ + async updateEventFeedbackSettings(eventId, settings) { + try { + const existing = await db('event_feedback_settings') + .where('event_id', eventId) + .first(); + + if (existing) { + await db('event_feedback_settings') + .where('event_id', eventId) + .update({ + ...settings, + updated_at: new Date() + }); + } else { + await db('event_feedback_settings').insert({ + event_id: eventId, + ...settings, + created_at: new Date(), + updated_at: new Date() + }); + } + + await logActivity('feedback_settings_updated', settings, eventId); + + return this.getEventFeedbackSettings(eventId); + } catch (error) { + logger.error('Error updating feedback settings:', error); + throw error; + } + } + + /** + * Submit feedback for a photo + */ + async submitFeedback(photoId, eventId, feedbackData, guestIdentifier) { + try { + const { feedback_type, rating, comment_text, guest_name, guest_email, ip_address, user_agent } = feedbackData; + + // Validate feedback type + if (!['rating', 'like', 'comment', 'favorite'].includes(feedback_type)) { + throw new Error('Invalid feedback type'); + } + + // Check if similar feedback already exists (prevent duplicates) + if (feedback_type !== 'comment') { + const existing = await db('photo_feedback') + .where({ + photo_id: photoId, + event_id: eventId, + feedback_type, + guest_identifier: guestIdentifier + }) + .first(); + + if (existing) { + if (feedback_type === 'rating' && rating !== existing.rating) { + // Update existing rating + await db('photo_feedback') + .where('id', existing.id) + .update({ + rating, + updated_at: new Date() + }); + + await this.updatePhotoFeedbackStats(photoId); + return { id: existing.id, updated: true }; + } + + // For likes and favorites, toggle off if already exists + if (feedback_type === 'like' || feedback_type === 'favorite') { + await db('photo_feedback') + .where('id', existing.id) + .delete(); + + await this.updatePhotoFeedbackStats(photoId); + return { removed: true }; + } + + return { id: existing.id, exists: true }; + } + } + + // Insert new feedback + const [id] = await db('photo_feedback').insert({ + photo_id: photoId, + event_id: eventId, + feedback_type, + rating: feedback_type === 'rating' ? rating : null, + comment_text: feedback_type === 'comment' ? comment_text : null, + guest_name, + guest_email, + guest_identifier: guestIdentifier, + ip_address, + user_agent, + is_approved: feedback_type !== 'comment' || !feedbackData.moderate_comments, + created_at: new Date(), + updated_at: new Date() + }); + + // Update photo stats + await this.updatePhotoFeedbackStats(photoId); + + // Log activity + await logActivity(`photo_${feedback_type}`, { photo_id: photoId }, eventId); + + return { id, created: true }; + } catch (error) { + logger.error('Error submitting feedback:', error); + throw error; + } + } + + /** + * Get feedback for a photo + */ + async getPhotoFeedback(photoId, options = {}) { + try { + const query = db('photo_feedback') + .where('photo_id', photoId); + + if (options.feedback_type) { + query.where('feedback_type', options.feedback_type); + } + + if (options.approved_only) { + query.where('is_approved', true); + } + + if (!options.include_hidden) { + query.where('is_hidden', false); + } + + if (options.guest_identifier) { + query.where('guest_identifier', options.guest_identifier); + } + + const feedback = await query + .orderBy('created_at', 'desc') + .select('id', 'feedback_type', 'rating', 'comment_text', 'guest_name', 'created_at'); + + return feedback; + } catch (error) { + logger.error('Error getting photo feedback:', error); + throw error; + } + } + + /** + * Get feedback summary for an event + */ + async getEventFeedbackSummary(eventId) { + try { + const photos = await db('photos') + .where('event_id', eventId) + .select('id', 'filename', 'feedback_count', 'like_count', 'average_rating', 'favorite_count') + .orderBy('average_rating', 'desc') + .orderBy('like_count', 'desc'); + + const totalStats = await db('photo_feedback') + .where('event_id', eventId) + .select( + db.raw('COUNT(DISTINCT CASE WHEN feedback_type = ? THEN guest_identifier END) as unique_raters', ['rating']), + db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_ratings', ['rating']), + db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_likes', ['like']), + db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_comments', ['comment']), + db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as total_favorites', ['favorite']) + ) + .first(); + + return { + photos, + stats: totalStats + }; + } catch (error) { + logger.error('Error getting feedback summary:', error); + throw error; + } + } + + /** + * Update photo feedback statistics + */ + async updatePhotoFeedbackStats(photoId) { + try { + // Get aggregated stats + const stats = await db('photo_feedback') + .where('photo_id', photoId) + .where('is_hidden', false) + .select( + db.raw('COUNT(CASE WHEN feedback_type = ? AND is_approved = ? THEN 1 END) as comment_count', ['comment', formatBoolean(true)]), + db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as like_count', ['like']), + db.raw('COUNT(CASE WHEN feedback_type = ? THEN 1 END) as favorite_count', ['favorite']), + db.raw('AVG(CASE WHEN feedback_type = ? THEN rating END) as average_rating', ['rating']), + db.raw('COUNT(DISTINCT guest_identifier) as feedback_count') + ) + .first(); + + // Update photo table + await db('photos') + .where('id', photoId) + .update({ + feedback_count: stats.feedback_count || 0, + like_count: stats.like_count || 0, + average_rating: stats.average_rating || 0, + favorite_count: stats.favorite_count || 0 + }); + } catch (error) { + logger.error('Error updating photo feedback stats:', error); + throw error; + } + } + + /** + * Moderate feedback (approve/hide) + */ + async moderateFeedback(feedbackId, action, adminId) { + try { + const updates = { + updated_at: new Date() + }; + + if (action === 'approve') { + updates.is_approved = true; + updates.is_hidden = false; + } else if (action === 'hide') { + updates.is_hidden = true; + } else if (action === 'reject') { + updates.is_approved = false; + updates.is_hidden = true; + } + + const feedback = await db('photo_feedback') + .where('id', feedbackId) + .first(); + + if (!feedback) { + throw new Error('Feedback not found'); + } + + await db('photo_feedback') + .where('id', feedbackId) + .update(updates); + + // Update photo stats if visibility changed + await this.updatePhotoFeedbackStats(feedback.photo_id); + + // Log moderation action + await logActivity('feedback_moderated', { + feedback_id: feedbackId, + action, + admin_id: adminId + }, feedback.event_id); + + return true; + } catch (error) { + logger.error('Error moderating feedback:', error); + throw error; + } + } + + /** + * Delete feedback + */ + async deleteFeedback(feedbackId, adminId) { + try { + const feedback = await db('photo_feedback') + .where('id', feedbackId) + .first(); + + if (!feedback) { + throw new Error('Feedback not found'); + } + + await db('photo_feedback') + .where('id', feedbackId) + .delete(); + + // Update photo stats + await this.updatePhotoFeedbackStats(feedback.photo_id); + + // Log deletion + await logActivity('feedback_deleted', { + feedback_id: feedbackId, + feedback_type: feedback.feedback_type, + admin_id: adminId + }, feedback.event_id); + + return true; + } catch (error) { + logger.error('Error deleting feedback:', error); + throw error; + } + } + + /** + * Get feedback requiring moderation + */ + async getPendingModeration(eventId = null) { + try { + let query = db('photo_feedback') + .join('photos', 'photo_feedback.photo_id', 'photos.id') + .join('events', 'photo_feedback.event_id', 'events.id') + .where('photo_feedback.is_approved', false) + .where('photo_feedback.is_hidden', false) + .where('photo_feedback.feedback_type', 'comment'); + + if (eventId) { + query = query.where('photo_feedback.event_id', eventId); + } + + const pending = await query + .select( + 'photo_feedback.*', + 'photos.filename as photo_filename', + 'events.event_name' + ) + .orderBy('photo_feedback.created_at', 'desc'); + + return pending; + } catch (error) { + logger.error('Error getting pending moderation:', error); + throw error; + } + } + + /** + * Export feedback data for an event + */ + async exportEventFeedback(eventId) { + try { + const feedback = await db('photo_feedback') + .join('photos', 'photo_feedback.photo_id', 'photos.id') + .where('photo_feedback.event_id', eventId) + .select( + 'photos.filename', + 'photo_feedback.feedback_type', + 'photo_feedback.rating', + 'photo_feedback.comment_text', + 'photo_feedback.guest_name', + 'photo_feedback.guest_email', + 'photo_feedback.created_at' + ) + .orderBy('photos.filename') + .orderBy('photo_feedback.created_at'); + + return feedback; + } catch (error) { + logger.error('Error exporting feedback:', error); + throw error; + } + } +} + +module.exports = new FeedbackService(); \ No newline at end of file diff --git a/backend/src/services/rateLimitService.js b/backend/src/services/rateLimitService.js index d08771a..27e0051 100644 --- a/backend/src/services/rateLimitService.js +++ b/backend/src/services/rateLimitService.js @@ -251,7 +251,7 @@ async function createAuthRateLimiter() { timestamp: new Date().toISOString(), headers: { 'x-forwarded-for': req.headers['x-forwarded-for'], - 'x-real-ip': req.headers['x-real-ip' + 'x-real-ip': req.headers['x-real-ip'] }, requestUrl: req.originalUrl, authType: req.path.includes('admin') ? 'admin' : 'gallery', diff --git a/backend/src/services/restoreService.js b/backend/src/services/restoreService.js new file mode 100644 index 0000000..05967f3 --- /dev/null +++ b/backend/src/services/restoreService.js @@ -0,0 +1,1221 @@ +const fs = require('fs').promises; +const path = require('path'); +const crypto = require('crypto'); +const zlib = require('zlib'); +const { pipeline } = require('stream/promises'); +const { createReadStream, createWriteStream } = require('fs'); +const { exec } = require('child_process'); +const { promisify } = require('util'); +const execAsync = promisify(exec); +const { db } = require('../database/db'); +const knexConfig = require('../../knexfile'); +const logger = require('../utils/logger'); +const backupManifest = require('./backupManifest'); +const S3StorageAdapter = require('./storage/s3Storage'); +const { queueEmail } = require('./emailProcessor'); +const { formatBoolean } = require('../utils/dbCompat'); +const os = require('os'); + +/** + * Restore Service with Extreme Safety Measures + * + * This service handles restoration of backups with multiple safety checks, + * validation, and rollback capabilities. It prioritizes data safety over speed. + * + * Features: + * - Pre-restore validation and integrity checks + * - Automatic pre-restore backup creation + * - Atomic operations where possible + * - Comprehensive rollback capability + * - Detailed logging of every action + * - Dry-run mode for testing + * - Multiple restore options (full, database-only, files-only, selective) + * - S3 support with resume capability + * - Post-restore verification + * + * @class RestoreService + */ +class RestoreService { + constructor() { + this.isRunning = false; + this.currentProgress = null; + this.restoreLog = []; + this.preRestoreBackupPath = null; + this.dbType = knexConfig.client === 'pg' ? 'postgresql' : 'sqlite'; + this.tempDir = path.join(os.tmpdir(), 'picpeak-restore'); + } + + /** + * Main restore method with comprehensive safety checks + * + * @param {Object} options - Restore options + * @param {string} options.source - Backup source (file path or S3 URL) + * @param {string} options.manifestPath - Path to backup manifest + * @param {string} options.restoreType - 'full', 'database', 'files', or 'selective' + * @param {Array} options.selectedItems - For selective restore, array of items to restore + * @param {boolean} options.dryRun - If true, performs validation only + * @param {boolean} options.skipPreBackup - Skip automatic pre-restore backup (dangerous!) + * @param {boolean} options.force - Force restore even with warnings (dangerous!) + * @param {Object} options.s3Config - S3 configuration for S3-based backups + * @returns {Promise} - Restore result + */ + async restore(options) { + if (this.isRunning) { + throw new Error('Restore operation already in progress'); + } + + this.isRunning = true; + this.restoreLog = []; + const startTime = new Date(); + let restoreRun = null; + + try { + // Validate options + this.validateRestoreOptions(options); + this.log('info', 'Starting restore operation', { options: this.sanitizeOptions(options) }); + + // Create restore run record + const [runId] = await db('restore_runs').insert({ + started_at: startTime, + status: 'running', + restore_type: options.restoreType, + source: options.source, + manifest_path: options.manifestPath, + is_dry_run: options.dryRun || false + }); + + restoreRun = { id: runId }; + + // Step 1: Load and validate manifest + this.updateProgress('Loading and validating manifest...'); + const manifest = await this.loadAndValidateManifest(options.manifestPath, options.s3Config); + this.log('info', 'Manifest loaded and validated', { + backupId: manifest.backup.id, + backupType: manifest.backup.type, + backupTime: manifest.backup.timestamp + }); + + // Step 2: Pre-restore validation + this.updateProgress('Performing pre-restore validation...'); + const validation = await this.performPreRestoreValidation(manifest, options); + + if (!validation.isValid && !options.force) { + throw new Error(`Pre-restore validation failed: ${validation.errors.join(', ')}`); + } + + if (validation.warnings.length > 0) { + this.log('warn', 'Pre-restore validation warnings', { warnings: validation.warnings }); + if (!options.force) { + throw new Error(`Restore blocked due to warnings (use force to override): ${validation.warnings.join(', ')}`); + } + } + + // Step 3: Check disk space + this.updateProgress('Checking available disk space...'); + const spaceCheck = await this.checkDiskSpace(manifest, options); + if (!spaceCheck.hasEnoughSpace) { + throw new Error(`Insufficient disk space. Required: ${spaceCheck.requiredFormatted}, Available: ${spaceCheck.availableFormatted}`); + } + + // Dry run mode - stop here after validation + if (options.dryRun) { + this.log('info', 'Dry run completed successfully'); + + await db('restore_runs').where('id', runId).update({ + completed_at: new Date(), + status: 'completed', + statistics: JSON.stringify({ + dryRun: true, + validation, + spaceCheck, + manifest: { + backupId: manifest.backup.id, + fileCount: manifest.files.count, + totalSize: manifest.files.total_size + } + }) + }); + + return { + success: true, + dryRun: true, + validation, + spaceCheck, + logs: this.restoreLog + }; + } + + // Step 4: Create pre-restore backup (unless explicitly skipped) + if (!options.skipPreBackup) { + this.updateProgress('Creating pre-restore safety backup...'); + this.preRestoreBackupPath = await this.createPreRestoreBackup(options); + this.log('info', 'Pre-restore backup created', { path: this.preRestoreBackupPath }); + } else { + this.log('warn', 'Pre-restore backup skipped at user request'); + } + + // Step 5: Download backup if from S3 + let localBackupPath = options.source; + if (options.source.startsWith('s3://')) { + this.updateProgress('Downloading backup from S3...'); + localBackupPath = await this.downloadFromS3(options.source, manifest, options); + } + + // Step 6: Perform the actual restore based on type + let restoreResult; + switch (options.restoreType) { + case 'full': + restoreResult = await this.performFullRestore(localBackupPath, manifest, options); + break; + case 'database': + restoreResult = await this.performDatabaseRestore(localBackupPath, manifest, options); + break; + case 'files': + restoreResult = await this.performFilesRestore(localBackupPath, manifest, options); + break; + case 'selective': + restoreResult = await this.performSelectiveRestore(localBackupPath, manifest, options); + break; + default: + throw new Error(`Unknown restore type: ${options.restoreType}`); + } + + // Step 7: Post-restore verification + this.updateProgress('Performing post-restore verification...'); + const verification = await this.performPostRestoreVerification(manifest, options); + + if (!verification.isValid) { + this.log('error', 'Post-restore verification failed', { errors: verification.errors }); + // Attempt rollback + if (this.preRestoreBackupPath) { + await this.attemptRollback(this.preRestoreBackupPath); + } + throw new Error(`Post-restore verification failed: ${verification.errors.join(', ')}`); + } + + // Step 8: Clean up temporary files + if (localBackupPath !== options.source) { + await fs.unlink(localBackupPath).catch(err => + this.log('warn', 'Failed to clean up temporary backup file', { error: err.message }) + ); + } + + // Calculate duration + const endTime = new Date(); + const durationSeconds = Math.round((endTime - startTime) / 1000); + + // Update restore run record + await db('restore_runs').where('id', runId).update({ + completed_at: endTime, + status: 'completed', + duration_seconds: durationSeconds, + pre_restore_backup_path: this.preRestoreBackupPath, + statistics: JSON.stringify({ + ...restoreResult, + verification, + durationSeconds + }) + }); + + // Send success notification + await this.sendRestoreNotification('success', { + restoreType: options.restoreType, + duration: durationSeconds, + filesRestored: restoreResult.filesRestored || 0, + backupId: manifest.backup.id + }); + + this.log('info', 'Restore completed successfully', { + duration: `${durationSeconds}s`, + result: restoreResult + }); + + return { + success: true, + duration: durationSeconds, + result: restoreResult, + verification, + preRestoreBackup: this.preRestoreBackupPath, + logs: this.restoreLog + }; + + } catch (error) { + this.log('error', 'Restore failed', { error: error.message, stack: error.stack }); + + // Update restore run record + if (restoreRun) { + await db('restore_runs').where('id', restoreRun.id).update({ + completed_at: new Date(), + status: 'failed', + error_message: error.message, + restore_log: JSON.stringify(this.restoreLog) + }); + } + + // Send failure notification + await this.sendRestoreNotification('failure', { + error: error.message, + restoreType: options.restoreType + }); + + throw error; + + } finally { + this.isRunning = false; + this.currentProgress = null; + + // Clean up temp directory + try { + await fs.rmdir(this.tempDir, { recursive: true }); + } catch (err) { + // Ignore cleanup errors + } + } + } + + /** + * Validate restore options + */ + validateRestoreOptions(options) { + if (!options.source) { + throw new Error('Backup source is required'); + } + + if (!options.manifestPath) { + throw new Error('Manifest path is required'); + } + + if (!options.restoreType) { + throw new Error('Restore type is required'); + } + + const validTypes = ['full', 'database', 'files', 'selective']; + if (!validTypes.includes(options.restoreType)) { + throw new Error(`Invalid restore type. Must be one of: ${validTypes.join(', ')}`); + } + + if (options.restoreType === 'selective' && (!options.selectedItems || options.selectedItems.length === 0)) { + throw new Error('Selected items are required for selective restore'); + } + + if (options.source.startsWith('s3://') && !options.s3Config) { + throw new Error('S3 configuration is required for S3-based backups'); + } + } + + /** + * Load and validate manifest + */ + async loadAndValidateManifest(manifestPath, s3Config) { + let manifest; + + if (manifestPath.startsWith('s3://')) { + // Download manifest from S3 + const tempManifestPath = path.join(this.tempDir, 'manifest.json'); + await fs.mkdir(this.tempDir, { recursive: true }); + await this.downloadFileFromS3(manifestPath, tempManifestPath, s3Config); + manifest = await backupManifest.loadManifest(tempManifestPath); + } else { + manifest = await backupManifest.loadManifest(manifestPath); + } + + // Validate manifest + backupManifest.validateManifest(manifest); + + return manifest; + } + + /** + * Perform pre-restore validation + */ + async performPreRestoreValidation(manifest, options) { + const validation = { + isValid: true, + errors: [], + warnings: [] + }; + + try { + // Check backup integrity + if (manifest.verification && manifest.verification.total_checksum) { + const calculatedChecksum = backupManifest.calculateManifestChecksum(manifest); + if (calculatedChecksum !== manifest.verification.total_checksum) { + validation.errors.push('Manifest checksum verification failed'); + validation.isValid = false; + } + } + + // Check backup age + const backupAge = Date.now() - new Date(manifest.backup.timestamp).getTime(); + const ageInDays = backupAge / (1000 * 60 * 60 * 24); + if (ageInDays > 30) { + validation.warnings.push(`Backup is ${Math.round(ageInDays)} days old`); + } + + // Check version compatibility + const currentVersion = require('../../package.json').version; + if (manifest.application.version !== currentVersion) { + validation.warnings.push( + `Version mismatch: backup from v${manifest.application.version}, current v${currentVersion}` + ); + } + + // Check database compatibility + if (options.restoreType === 'full' || options.restoreType === 'database') { + const currentDbType = this.dbType; + if (manifest.database.type !== currentDbType) { + validation.errors.push( + `Database type mismatch: backup is ${manifest.database.type}, current is ${currentDbType}` + ); + validation.isValid = false; + } + } + + // Check if restoring would overwrite existing data + if (options.restoreType === 'full' || options.restoreType === 'database') { + const eventCount = await db('events').count('* as count').first(); + if (eventCount && eventCount.count > 0) { + validation.warnings.push(`Database contains ${eventCount.count} existing events that will be overwritten`); + } + } + + // Check for active users + const activeUsers = await db('admin_users') + .where('is_active', formatBoolean(true)) + .count('* as count') + .first(); + if (activeUsers && activeUsers.count > 0) { + validation.warnings.push(`There are ${activeUsers.count} active admin users`); + } + + } catch (error) { + validation.errors.push(`Validation error: ${error.message}`); + validation.isValid = false; + } + + return validation; + } + + /** + * Check available disk space + */ + async checkDiskSpace(manifest, options) { + const { statvfs } = require('fs'); + const statvfsAsync = promisify(statvfs); + + try { + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const stats = await statvfsAsync(storagePath); + + const blockSize = stats.bsize || stats.f_bsize || 4096; + const availableBytes = stats.bavail * blockSize; + + // Calculate required space (with 20% buffer) + let requiredBytes = 0; + if (options.restoreType === 'full' || options.restoreType === 'files') { + requiredBytes = manifest.files.total_size * 1.2; + } + if (options.restoreType === 'full' || options.restoreType === 'database') { + requiredBytes += (manifest.database.size || 0) * 1.2; + } + + // Add space for pre-restore backup + if (!options.skipPreBackup) { + const currentUsage = await this.calculateCurrentStorageUsage(); + requiredBytes += currentUsage * 1.1; // 10% buffer for backup + } + + return { + hasEnoughSpace: availableBytes > requiredBytes, + availableBytes, + requiredBytes, + availableFormatted: this.formatBytes(availableBytes), + requiredFormatted: this.formatBytes(requiredBytes) + }; + + } catch (error) { + // Fallback for systems without statvfs + this.log('warn', 'Could not check disk space', { error: error.message }); + return { + hasEnoughSpace: true, // Assume we have space if we can't check + availableBytes: 0, + requiredBytes: 0, + availableFormatted: 'Unknown', + requiredFormatted: 'Unknown' + }; + } + } + + /** + * Create pre-restore backup + */ + async createPreRestoreBackup(options) { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const backupName = `pre-restore-${timestamp}`; + const backupPath = path.join(this.tempDir, backupName); + + await fs.mkdir(backupPath, { recursive: true }); + + try { + // Backup database + if (options.restoreType === 'full' || options.restoreType === 'database') { + this.log('info', 'Backing up current database...'); + const dbBackupPath = path.join(backupPath, 'database.sql'); + + if (this.dbType === 'sqlite') { + const dbPath = knexConfig.connection.filename; + await execAsync(`sqlite3 "${dbPath}" ".backup '${dbBackupPath}'"`); + } else { + // PostgreSQL backup + const { host, port, user, password, database } = knexConfig.connection; + const env = { ...process.env, PGPASSWORD: password }; + await execAsync( + `pg_dump -h ${host} -p ${port} -U ${user} -d ${database} > "${dbBackupPath}"`, + { env } + ); + } + + // Compress database backup + await this.compressFile(dbBackupPath, `${dbBackupPath}.gz`); + await fs.unlink(dbBackupPath); + } + + // Backup files + if (options.restoreType === 'full' || options.restoreType === 'files') { + this.log('info', 'Backing up current files...'); + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const filesBackupPath = path.join(backupPath, 'files.tar.gz'); + + await execAsync(`tar -czf "${filesBackupPath}" -C "${path.dirname(storagePath)}" "${path.basename(storagePath)}"`); + } + + // Create backup manifest + const backupManifest = { + timestamp: new Date().toISOString(), + type: 'pre-restore-safety-backup', + restoreOptions: this.sanitizeOptions(options), + contents: await fs.readdir(backupPath) + }; + + await fs.writeFile( + path.join(backupPath, 'backup-manifest.json'), + JSON.stringify(backupManifest, null, 2) + ); + + return backupPath; + + } catch (error) { + // Clean up on failure + await fs.rmdir(backupPath, { recursive: true }).catch(() => {}); + throw new Error(`Failed to create pre-restore backup: ${error.message}`); + } + } + + /** + * Download backup from S3 + */ + async downloadFromS3(s3Url, manifest, options) { + const s3PathMatch = s3Url.match(/^s3:\/\/([^\/]+)\/(.+)$/); + if (!s3PathMatch) { + throw new Error('Invalid S3 URL format'); + } + + const [, bucket, prefix] = s3PathMatch; + const s3Client = new S3StorageAdapter({ + ...options.s3Config, + bucket + }); + + const localPath = path.join(this.tempDir, 'restore-download'); + await fs.mkdir(localPath, { recursive: true }); + + try { + // Test S3 connection + await s3Client.testConnection(); + + // Download database backup if needed + if (options.restoreType === 'full' || options.restoreType === 'database') { + if (manifest.database.backup_file) { + const dbS3Key = path.posix.join(prefix, 'database', path.basename(manifest.database.backup_file)); + const localDbPath = path.join(localPath, 'database', path.basename(manifest.database.backup_file)); + + await fs.mkdir(path.dirname(localDbPath), { recursive: true }); + + this.log('info', 'Downloading database backup from S3...', { key: dbS3Key }); + await s3Client.download(dbS3Key, localDbPath, { + onProgress: (loaded, total) => { + const percent = Math.round((loaded / total) * 100); + this.updateProgress(`Downloading database backup: ${percent}%`); + } + }); + } + } + + // Download files if needed + if (options.restoreType === 'full' || options.restoreType === 'files') { + const filesToDownload = options.restoreType === 'selective' + ? options.selectedItems + : manifest.files.manifest; + + let downloaded = 0; + for (const file of filesToDownload) { + const s3Key = path.posix.join(prefix, file.path); + const localFilePath = path.join(localPath, file.path); + + await fs.mkdir(path.dirname(localFilePath), { recursive: true }); + + try { + await s3Client.download(s3Key, localFilePath, { + onProgress: (loaded, total) => { + const filePercent = Math.round((loaded / total) * 100); + const totalPercent = Math.round(((downloaded + (loaded / total)) / filesToDownload.length) * 100); + this.updateProgress(`Downloading files: ${totalPercent}% (${file.path}: ${filePercent}%)`); + } + }); + + // Verify checksum if available + if (file.checksum) { + const downloadedChecksum = await this.calculateChecksum(localFilePath); + if (downloadedChecksum !== file.checksum) { + throw new Error(`Checksum mismatch for ${file.path}`); + } + } + + downloaded++; + } catch (error) { + this.log('error', `Failed to download ${file.path}`, { error: error.message }); + throw error; + } + } + } + + return localPath; + + } catch (error) { + // Clean up on failure + await fs.rmdir(localPath, { recursive: true }).catch(() => {}); + throw new Error(`Failed to download from S3: ${error.message}`); + } + } + + /** + * Perform full restore (database + files) + */ + async performFullRestore(backupPath, manifest, options) { + const result = { + databaseRestored: false, + filesRestored: 0, + errors: [] + }; + + try { + // Restore database first + const dbResult = await this.performDatabaseRestore(backupPath, manifest, options); + result.databaseRestored = dbResult.success; + + // Then restore files + const filesResult = await this.performFilesRestore(backupPath, manifest, options); + result.filesRestored = filesResult.filesRestored; + + return result; + + } catch (error) { + result.errors.push(error.message); + throw new Error(`Full restore failed: ${error.message}`); + } + } + + /** + * Perform database-only restore + */ + async performDatabaseRestore(backupPath, manifest, options) { + this.updateProgress('Restoring database...'); + + const dbBackupFile = manifest.database.backup_file; + if (!dbBackupFile) { + throw new Error('No database backup file found in manifest'); + } + + const dbBackupPath = path.join(backupPath, 'database', path.basename(dbBackupFile)); + + // Check if backup file exists + try { + await fs.access(dbBackupPath); + } catch (error) { + throw new Error(`Database backup file not found: ${dbBackupPath}`); + } + + // Decompress if needed + let restoreFile = dbBackupPath; + if (dbBackupPath.endsWith('.gz')) { + this.log('info', 'Decompressing database backup...'); + const decompressedPath = dbBackupPath.replace('.gz', ''); + await this.decompressFile(dbBackupPath, decompressedPath); + restoreFile = decompressedPath; + } + + try { + if (this.dbType === 'sqlite') { + // SQLite restore + const dbPath = knexConfig.connection.filename; + + // Close all database connections + await db.destroy(); + + // Backup current database + const currentBackup = `${dbPath}.restore-backup`; + await fs.copyFile(dbPath, currentBackup); + + try { + // Restore from backup + await execAsync(`sqlite3 "${dbPath}" ".restore '${restoreFile}'"`); + + // Verify integrity + const integrityCheck = await execAsync(`sqlite3 "${dbPath}" "PRAGMA integrity_check"`); + if (!integrityCheck.stdout.includes('ok')) { + throw new Error('Database integrity check failed after restore'); + } + + // Remove backup of previous database + await fs.unlink(currentBackup); + + } catch (error) { + // Rollback on failure + await fs.copyFile(currentBackup, dbPath); + await fs.unlink(currentBackup); + throw error; + } + + } else { + // PostgreSQL restore + const { host, port, user, password, database } = knexConfig.connection; + const env = { ...process.env, PGPASSWORD: password }; + + // Drop and recreate database (extremely dangerous!) + this.log('warn', 'Dropping and recreating PostgreSQL database...'); + + await execAsync( + `psql -h ${host} -p ${port} -U ${user} -c "DROP DATABASE IF EXISTS ${database}"`, + { env } + ); + + await execAsync( + `psql -h ${host} -p ${port} -U ${user} -c "CREATE DATABASE ${database}"`, + { env } + ); + + // Restore from backup + await execAsync( + `psql -h ${host} -p ${port} -U ${user} -d ${database} < "${restoreFile}"`, + { env, maxBuffer: 1024 * 1024 * 100 } // 100MB buffer + ); + } + + // Re-initialize database connection + const { db: newDb } = require('../database/db'); + + // Run migrations to ensure schema is up to date + this.log('info', 'Running database migrations...'); + await newDb.migrate.latest(); + + return { success: true }; + + } catch (error) { + this.log('error', 'Database restore failed', { error: error.message }); + throw error; + } finally { + // Clean up decompressed file + if (restoreFile !== dbBackupPath) { + await fs.unlink(restoreFile).catch(() => {}); + } + } + } + + /** + * Perform files-only restore + */ + async performFilesRestore(backupPath, manifest, options) { + this.updateProgress('Restoring files...'); + + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const filesToRestore = options.restoreType === 'selective' + ? options.selectedItems.filter(item => item.type === 'file') + : manifest.files.manifest; + + let restoredCount = 0; + const errors = []; + + for (const file of filesToRestore) { + try { + const sourcePath = path.join(backupPath, file.path); + const targetPath = path.join(storagePath, file.path); + + // Check if source file exists + try { + await fs.access(sourcePath); + } catch (error) { + errors.push(`Source file not found: ${file.path}`); + continue; + } + + // Create target directory + await fs.mkdir(path.dirname(targetPath), { recursive: true }); + + // Check if target exists and create backup + let targetBackup = null; + try { + await fs.access(targetPath); + targetBackup = `${targetPath}.restore-backup`; + await fs.copyFile(targetPath, targetBackup); + } catch (error) { + // Target doesn't exist, no backup needed + } + + try { + // Copy file + await fs.copyFile(sourcePath, targetPath); + + // Verify checksum if available + if (file.checksum) { + const restoredChecksum = await this.calculateChecksum(targetPath); + if (restoredChecksum !== file.checksum) { + throw new Error('Checksum verification failed'); + } + } + + // Set file permissions if available + if (file.permissions) { + await fs.chmod(targetPath, file.permissions); + } + + // Set modification time + if (file.modified) { + const mtime = new Date(file.modified); + await fs.utimes(targetPath, mtime, mtime); + } + + // Remove backup of previous file + if (targetBackup) { + await fs.unlink(targetBackup); + } + + restoredCount++; + + if (restoredCount % 100 === 0) { + this.updateProgress(`Restored ${restoredCount}/${filesToRestore.length} files`); + } + + } catch (error) { + // Rollback on failure + if (targetBackup) { + await fs.copyFile(targetBackup, targetPath); + await fs.unlink(targetBackup); + } + throw error; + } + + } catch (error) { + errors.push(`Failed to restore ${file.path}: ${error.message}`); + this.log('error', `Failed to restore file ${file.path}`, { error: error.message }); + } + } + + if (errors.length > 0 && errors.length === filesToRestore.length) { + throw new Error('All file restorations failed'); + } + + return { + filesRestored: restoredCount, + totalFiles: filesToRestore.length, + errors + }; + } + + /** + * Perform selective restore + */ + async performSelectiveRestore(backupPath, manifest, options) { + const result = { + itemsRestored: 0, + errors: [] + }; + + // Separate selected items by type + const databaseItems = options.selectedItems.filter(item => item.type === 'database'); + const fileItems = options.selectedItems.filter(item => item.type === 'file'); + + // Restore database items (tables) + if (databaseItems.length > 0) { + this.log('warn', 'Selective database restore not implemented yet'); + result.errors.push('Selective database restore not implemented'); + } + + // Restore file items + if (fileItems.length > 0) { + const filesResult = await this.performFilesRestore(backupPath, manifest, { + ...options, + selectedItems: fileItems + }); + result.itemsRestored += filesResult.filesRestored; + result.errors.push(...filesResult.errors); + } + + return result; + } + + /** + * Perform post-restore verification + */ + async performPostRestoreVerification(manifest, options) { + const verification = { + isValid: true, + errors: [], + checksums: {} + }; + + try { + // Verify database + if (options.restoreType === 'full' || options.restoreType === 'database') { + // Check database connectivity + try { + await db.raw('SELECT 1'); + } catch (error) { + verification.errors.push('Database connection failed after restore'); + verification.isValid = false; + } + + // Compare table checksums if available + if (manifest.database.row_counts) { + for (const [table, expected] of Object.entries(manifest.database.row_counts)) { + try { + const result = await db(table).count('* as count').first(); + if (result.count !== expected.rowCount) { + verification.errors.push( + `Table ${table} row count mismatch: expected ${expected.rowCount}, got ${result.count}` + ); + } + } catch (error) { + verification.errors.push(`Failed to verify table ${table}: ${error.message}`); + } + } + } + } + + // Verify files + if (options.restoreType === 'full' || options.restoreType === 'files') { + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + const filesToVerify = options.restoreType === 'selective' + ? options.selectedItems.filter(item => item.type === 'file') + : manifest.files.manifest.slice(0, 100); // Verify first 100 files for performance + + for (const file of filesToVerify) { + const filePath = path.join(storagePath, file.path); + try { + await fs.access(filePath); + + if (file.checksum) { + const actualChecksum = await this.calculateChecksum(filePath); + verification.checksums[file.path] = { + expected: file.checksum, + actual: actualChecksum, + match: actualChecksum === file.checksum + }; + + if (actualChecksum !== file.checksum) { + verification.errors.push(`Checksum mismatch for ${file.path}`); + } + } + } catch (error) { + verification.errors.push(`File not found after restore: ${file.path}`); + } + } + } + + } catch (error) { + verification.errors.push(`Verification error: ${error.message}`); + verification.isValid = false; + } + + verification.isValid = verification.errors.length === 0; + return verification; + } + + /** + * Attempt rollback using pre-restore backup + */ + async attemptRollback(preRestoreBackupPath) { + this.log('warn', 'Attempting rollback to pre-restore state...'); + + try { + // Read backup manifest + const manifestPath = path.join(preRestoreBackupPath, 'backup-manifest.json'); + const backupManifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')); + + // Restore database if backed up + const dbBackupPath = path.join(preRestoreBackupPath, 'database.sql.gz'); + if (await fs.access(dbBackupPath).then(() => true).catch(() => false)) { + const decompressedPath = dbBackupPath.replace('.gz', ''); + await this.decompressFile(dbBackupPath, decompressedPath); + + if (this.dbType === 'sqlite') { + const dbPath = knexConfig.connection.filename; + await execAsync(`sqlite3 "${dbPath}" ".restore '${decompressedPath}'"`); + } else { + const { host, port, user, password, database } = knexConfig.connection; + const env = { ...process.env, PGPASSWORD: password }; + await execAsync( + `psql -h ${host} -p ${port} -U ${user} -d ${database} < "${decompressedPath}"`, + { env } + ); + } + + await fs.unlink(decompressedPath); + } + + // Restore files if backed up + const filesBackupPath = path.join(preRestoreBackupPath, 'files.tar.gz'); + if (await fs.access(filesBackupPath).then(() => true).catch(() => false)) { + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + await execAsync(`tar -xzf "${filesBackupPath}" -C "${path.dirname(storagePath)}"`); + } + + this.log('info', 'Rollback completed successfully'); + + } catch (error) { + this.log('error', 'Rollback failed', { error: error.message }); + throw new Error(`Rollback failed: ${error.message}. Manual intervention may be required.`); + } + } + + // Utility methods + + /** + * Calculate file checksum + */ + 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 file using gzip + */ + async compressFile(inputPath, outputPath) { + const gzip = zlib.createGzip({ level: 6 }); + const source = createReadStream(inputPath); + const destination = createWriteStream(outputPath); + await pipeline(source, gzip, destination); + } + + /** + * Decompress gzip file + */ + async decompressFile(inputPath, outputPath) { + const gunzip = zlib.createGunzip(); + const source = createReadStream(inputPath); + const destination = createWriteStream(outputPath); + await pipeline(source, gunzip, destination); + } + + /** + * Download file from S3 + */ + async downloadFileFromS3(s3Url, localPath, s3Config) { + const s3PathMatch = s3Url.match(/^s3:\/\/([^\/]+)\/(.+)$/); + if (!s3PathMatch) { + throw new Error('Invalid S3 URL format'); + } + + const [, bucket, key] = s3PathMatch; + const s3Client = new S3StorageAdapter({ + ...s3Config, + bucket + }); + + await s3Client.download(key, localPath); + } + + /** + * Calculate current storage usage + */ + async calculateCurrentStorageUsage() { + const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage'); + + let totalSize = 0; + async function calculateDirSize(dirPath) { + const entries = await fs.readdir(dirPath, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name); + if (entry.isDirectory()) { + await calculateDirSize(fullPath); + } else if (entry.isFile()) { + const stats = await fs.stat(fullPath); + totalSize += stats.size; + } + } + } + + await calculateDirSize(storagePath); + return totalSize; + } + + /** + * Format bytes to human readable + */ + 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]; + } + + /** + * Update progress + */ + updateProgress(message, details = {}) { + this.currentProgress = { + message, + details, + timestamp: new Date() + }; + logger.info(`Restore progress: ${message}`, details); + } + + /** + * Get current progress + */ + getProgress() { + return this.currentProgress; + } + + /** + * Log message + */ + log(level, message, details = {}) { + const logEntry = { + timestamp: new Date().toISOString(), + level, + message, + details + }; + + this.restoreLog.push(logEntry); + logger[level](message, details); + } + + /** + * Sanitize options for logging + */ + sanitizeOptions(options) { + const sanitized = { ...options }; + if (sanitized.s3Config) { + sanitized.s3Config = { + ...sanitized.s3Config, + accessKeyId: sanitized.s3Config.accessKeyId ? '***' : undefined, + secretAccessKey: sanitized.s3Config.secretAccessKey ? '***' : undefined + }; + } + return sanitized; + } + + /** + * Send restore notification + */ + async sendRestoreNotification(type, details) { + try { + const admins = await db('admin_users').where('is_active', formatBoolean(true)); + + for (const admin of admins) { + if (type === 'success') { + await queueEmail(null, admin.email, 'restore_completed', { + restore_type: details.restoreType, + duration: `${details.duration} seconds`, + files_restored: details.filesRestored, + backup_id: details.backupId, + timestamp: new Date().toISOString() + }); + } else { + await queueEmail(null, admin.email, 'restore_failed', { + restore_type: details.restoreType, + error_message: details.error, + timestamp: new Date().toISOString() + }); + } + } + } catch (error) { + this.log('error', 'Failed to send restore notification', { error: error.message }); + } + } + + /** + * Get restore history + */ + async getRestoreHistory(limit = 10) { + return await db('restore_runs') + .orderBy('started_at', 'desc') + .limit(limit); + } + + /** + * Generate restore report + */ + generateRestoreReport(restoreResult) { + const report = []; + + report.push('=== RESTORE OPERATION REPORT ==='); + report.push(`Status: ${restoreResult.success ? 'SUCCESS' : 'FAILED'}`); + report.push(`Duration: ${restoreResult.duration}s`); + report.push(`Dry Run: ${restoreResult.dryRun ? 'Yes' : 'No'}`); + + if (restoreResult.result) { + report.push('\n--- Restore Results ---'); + report.push(`Database Restored: ${restoreResult.result.databaseRestored ? 'Yes' : 'No'}`); + report.push(`Files Restored: ${restoreResult.result.filesRestored || 0}`); + if (restoreResult.result.errors && restoreResult.result.errors.length > 0) { + report.push(`Errors: ${restoreResult.result.errors.length}`); + restoreResult.result.errors.forEach(err => report.push(` - ${err}`)); + } + } + + if (restoreResult.verification) { + report.push('\n--- Verification Results ---'); + report.push(`Valid: ${restoreResult.verification.isValid ? 'Yes' : 'No'}`); + if (restoreResult.verification.errors.length > 0) { + report.push('Errors:'); + restoreResult.verification.errors.forEach(err => report.push(` - ${err}`)); + } + } + + if (restoreResult.preRestoreBackup) { + report.push('\n--- Safety Backup ---'); + report.push(`Location: ${restoreResult.preRestoreBackup}`); + } + + report.push('\n--- Operation Log ---'); + restoreResult.logs.forEach(log => { + report.push(`[${log.timestamp}] ${log.level.toUpperCase()}: ${log.message}`); + }); + + return report.join('\n'); + } +} + +// Create singleton instance +const restoreService = new RestoreService(); + +module.exports = { + restoreService, + RestoreService // Export class for testing +}; \ No newline at end of file diff --git a/backend/src/services/storage/__tests__/s3Storage.test.js b/backend/src/services/storage/__tests__/s3Storage.test.js new file mode 100644 index 0000000..052ac48 --- /dev/null +++ b/backend/src/services/storage/__tests__/s3Storage.test.js @@ -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'); + }); + }); +}); \ No newline at end of file diff --git a/backend/src/services/storage/s3Storage.example.js b/backend/src/services/storage/s3Storage.example.js new file mode 100644 index 0000000..51e7da3 --- /dev/null +++ b/backend/src/services/storage/s3Storage.example.js @@ -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 }; \ No newline at end of file diff --git a/backend/src/services/storage/s3Storage.js b/backend/src/services/storage/s3Storage.js new file mode 100644 index 0000000..fe6b3c3 --- /dev/null +++ b/backend/src/services/storage/s3Storage.js @@ -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} - 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} - 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} - 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} + */ + 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} - 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} + */ + 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} - 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} - 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} - 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} - 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} - 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} - 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 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} - 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} - 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} - 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; \ No newline at end of file diff --git a/backend/src/utils/feedbackValidation.js b/backend/src/utils/feedbackValidation.js new file mode 100644 index 0000000..2bb14b8 --- /dev/null +++ b/backend/src/utils/feedbackValidation.js @@ -0,0 +1,253 @@ +const { body, param, validationResult } = require('express-validator'); +const validator = require('validator'); + +/** + * Validation rules for feedback submission + */ +const feedbackValidationRules = { + rating: [ + body('feedback_type').equals('rating'), + body('rating') + .isInt({ min: 1, max: 5 }) + .withMessage('Rating must be between 1 and 5'), + body('guest_name') + .optional() + .trim() + .isLength({ max: 100 }) + .withMessage('Name must be less than 100 characters'), + body('guest_email') + .optional() + .trim() + .isEmail() + .normalizeEmail() + .withMessage('Invalid email address') + ], + + like: [ + body('feedback_type').equals('like'), + body('guest_name') + .optional() + .trim() + .isLength({ max: 100 }), + body('guest_email') + .optional() + .trim() + .isEmail() + .normalizeEmail() + ], + + favorite: [ + body('feedback_type').equals('favorite'), + body('guest_name') + .optional() + .trim() + .isLength({ max: 100 }), + body('guest_email') + .optional() + .trim() + .isEmail() + .normalizeEmail() + ], + + comment: [ + body('feedback_type').equals('comment'), + body('comment_text') + .trim() + .notEmpty() + .withMessage('Comment cannot be empty') + .isLength({ min: 1, max: 1000 }) + .withMessage('Comment must be between 1 and 1000 characters') + .customSanitizer(value => sanitizeComment(value)), + body('guest_name') + .optional() + .trim() + .isLength({ max: 100 }) + .withMessage('Name must be less than 100 characters'), + body('guest_email') + .optional() + .trim() + .isEmail() + .normalizeEmail() + .withMessage('Invalid email address') + ] +}; + +/** + * Sanitize comment text + */ +function sanitizeComment(text) { + if (!text) return ''; + + // Remove excessive whitespace + text = text.replace(/\s+/g, ' ').trim(); + + // Remove zero-width characters + text = text.replace(/[\u200B-\u200D\uFEFF]/g, ''); + + // Remove control characters + text = text.replace(/[\x00-\x1F\x7F]/g, ''); + + // Limit consecutive special characters + text = text.replace(/([!?.]){4,}/g, '$1$1$1'); + + // Remove script tags and other dangerous HTML (basic sanitization) + text = text.replace(/]*>[\s\S]*?<\/script>/gi, ''); + text = text.replace(/]*>[\s\S]*?<\/iframe>/gi, ''); + text = text.replace(/]*>[\s\S]*?<\/object>/gi, ''); + text = text.replace(/]*>/gi, ''); + + return text; +} + +/** + * Validate feedback type parameter + */ +const validateFeedbackType = param('feedbackType') + .isIn(['rating', 'like', 'comment', 'favorite']) + .withMessage('Invalid feedback type'); + +/** + * Validate photo ID parameter + */ +const validatePhotoId = param('photoId') + .isInt({ min: 1 }) + .withMessage('Invalid photo ID'); + +/** + * Validate event ID parameter + */ +const validateEventId = param('eventId') + .isInt({ min: 1 }) + .withMessage('Invalid event ID'); + +/** + * Get validation rules based on feedback type + */ +function getValidationRules(feedbackType) { + return feedbackValidationRules[feedbackType] || []; +} + +/** + * Validation middleware for feedback submission + */ +const validateFeedbackSubmission = [ + body('feedback_type') + .isIn(['rating', 'like', 'comment', 'favorite']) + .withMessage('Invalid feedback type'), + + // Conditional validation based on feedback type + body('rating') + .if(body('feedback_type').equals('rating')) + .isInt({ min: 1, max: 5 }) + .withMessage('Rating must be between 1 and 5'), + + body('comment_text') + .if(body('feedback_type').equals('comment')) + .trim() + .notEmpty() + .withMessage('Comment cannot be empty') + .isLength({ min: 1, max: 1000 }) + .withMessage('Comment must be between 1 and 1000 characters') + .customSanitizer(value => sanitizeComment(value)), + + body('guest_name') + .optional() + .trim() + .isLength({ max: 100 }) + .withMessage('Name must be less than 100 characters') + .matches(/^[a-zA-Z0-9\s\-'.]+$/) + .withMessage('Name contains invalid characters'), + + body('guest_email') + .optional() + .trim() + .isEmail() + .normalizeEmail() + .withMessage('Invalid email address') +]; + +/** + * Validation for feedback settings + */ +const validateFeedbackSettings = [ + body('feedback_enabled').optional().isBoolean(), + body('allow_ratings').optional().isBoolean(), + body('allow_likes').optional().isBoolean(), + body('allow_comments').optional().isBoolean(), + body('allow_favorites').optional().isBoolean(), + body('require_name_email').optional().isBoolean(), + body('moderate_comments').optional().isBoolean(), + body('show_feedback_to_guests').optional().isBoolean() +]; + +/** + * Validation for word filters + */ +const validateWordFilter = [ + body('word') + .trim() + .notEmpty() + .withMessage('Word cannot be empty') + .isLength({ min: 2, max: 100 }) + .withMessage('Word must be between 2 and 100 characters'), + body('severity') + .optional() + .isIn(['mild', 'moderate', 'severe']) + .withMessage('Invalid severity level') +]; + +/** + * Check validation results middleware + */ +const checkValidation = (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + return res.status(400).json({ + error: 'Validation failed', + errors: errors.array() + }); + } + next(); +}; + +/** + * Validate guest identity requirements + */ +async function validateGuestRequirements(settings, guestData) { + if (!settings.require_name_email) { + return { valid: true }; + } + + const errors = []; + + if (!guestData.guest_name || guestData.guest_name.trim().length === 0) { + errors.push('Name is required'); + } + + if (!guestData.guest_email || !validator.isEmail(guestData.guest_email)) { + errors.push('Valid email is required'); + } + + if (errors.length > 0) { + return { + valid: false, + errors + }; + } + + return { valid: true }; +} + +module.exports = { + feedbackValidationRules, + validateFeedbackType, + validatePhotoId, + validateEventId, + validateFeedbackSubmission, + validateFeedbackSettings, + validateWordFilter, + checkValidation, + getValidationRules, + sanitizeComment, + validateGuestRequirements +}; \ No newline at end of file diff --git a/backend/src/utils/passwordValidation.js b/backend/src/utils/passwordValidation.js index 4274cfd..64b1bab 100644 --- a/backend/src/utils/passwordValidation.js +++ b/backend/src/utils/passwordValidation.js @@ -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 }; \ No newline at end of file diff --git a/frontend/.claudedocs/scans/security-2025-01-12.md b/frontend/.claudedocs/scans/security-2025-01-12.md deleted file mode 100644 index 77b87fe..0000000 --- a/frontend/.claudedocs/scans/security-2025-01-12.md +++ /dev/null @@ -1,263 +0,0 @@ -# PicPeak Security Scan Report - -**Date**: January 12, 2025 -**Scan Type**: Comprehensive Security Audit -**Platform**: PicPeak Photo Sharing Platform -**Scanner**: Claude Code Security Scanner - -## Executive Summary - -A comprehensive security scan of the PicPeak photo sharing platform reveals **critical vulnerabilities** that require immediate attention. While the application implements some security best practices, several high-severity issues could lead to data breaches, unauthorized access, and system compromise. - -### Overall Risk Assessment: **HIGH** πŸ”΄ - -**Critical Issues Found**: 8 -**High-Risk Issues**: 7 -**Medium-Risk Issues**: 6 -**Low-Risk Issues**: 2 - -## Critical Vulnerabilities Requiring Immediate Action - -### 1. Hardcoded Secrets and Credentials πŸ”΄ - -#### JWT Secret Fallback -- **Location**: `backend/src/routes/protectedImages.js:15,27` -- **Severity**: CRITICAL -- **Impact**: Complete authentication bypass if environment variable not set -```javascript -const secret = process.env.JWT_SECRET || 'your-secret-key'; // VULNERABLE -``` - -#### Default Admin Password -- **Location**: `backend/migrations/init.js:14`, `setup-remaining-files.sh:121` -- **Severity**: HIGH -- **Impact**: Known default credentials allow unauthorized admin access -- **Current**: Hardcoded `admin123` password - -### 2. SQL Injection Vulnerabilities πŸ”΄ - -#### Direct Template Literal Interpolation -- **Location**: `backend/src/routes/adminDashboard.js:214,221,227,252,269` -- **Severity**: HIGH -- **Impact**: Potential database compromise -```javascript -.whereRaw(`timestamp >= datetime("now", "-${days} days")`) // VULNERABLE -``` - -#### LIKE Query Injection -- **Locations**: - - `backend/src/routes/adminPhotos.js:476` - - `backend/src/routes/adminEvents.js:156-158` -- **Severity**: MEDIUM -- **Impact**: Query manipulation through special characters - -### 3. Authentication & Authorization Flaws πŸ”΄ - -#### Missing Token Type Validation -- **Location**: Admin middleware -- **Severity**: HIGH -- **Impact**: Gallery tokens could potentially access admin endpoints - -#### Weak Password Requirements -- **Current**: Only 6 characters minimum -- **Severity**: MEDIUM -- **Impact**: Vulnerable to brute force attacks - -#### Rate Limiting Bypass -- **Location**: `backend/server.js:57-73` -- **Severity**: HIGH -- **Impact**: Invalid JWT tokens bypass rate limiting - -### 4. Cross-Site Scripting (XSS) πŸ”΄ - -#### Stored XSS in CMS -- **Location**: `frontend/src/pages/public/LegalPage.tsx:106` -- **Severity**: CRITICAL -- **Impact**: Malicious scripts execute for all visitors -```tsx -dangerouslySetInnerHTML={{ __html: page.content }} // VULNERABLE -``` - -### 5. File Upload Vulnerabilities 🟑 - -#### Path Traversal Risk -- **Location**: `backend/server.js:104-110` -- **Severity**: HIGH -- **Impact**: Access to files outside intended directories - -#### Insufficient MIME Type Validation -- **Multiple locations** -- **Severity**: MEDIUM -- **Impact**: Malicious file upload bypass - -### 6. Security Headers & Configuration 🟑 - -#### Missing Critical Headers -- **Missing**: CSP, X-Frame-Options, Strict-Transport-Security -- **Severity**: MEDIUM -- **Impact**: Reduced defense against various attacks - -#### Permissive CORS Configuration -- **Location**: `backend/server.js:30-49` -- **Severity**: MEDIUM -- **Impact**: Allows multiple origins including localhost - -## Dependency Analysis - -### NPM Audit Results βœ… -- **Backend**: 0 vulnerabilities found -- **Frontend**: 0 vulnerabilities found -- **Status**: All dependencies are up to date - -## Detailed Findings by Category - -### Authentication Security - -1. **JWT Implementation Issues**: - - No refresh token mechanism - - 24-hour token expiration for all types - - No token revocation capability - - Hardcoded fallback secret - -2. **Session Management**: - - In-memory session storage (not scalable) - - No Redis implementation despite comments - - Incomplete session cleanup - -3. **Password Security**: - - Weak requirements (6 chars minimum) - - Fixed bcrypt rounds (10) - - No password complexity requirements - - No breach checking - -### Data Security - -1. **SQL Injection Risks**: - - Template literal interpolation in whereRaw() - - Unescaped LIKE queries - - Missing input validation on some parameters - -2. **XSS Vulnerabilities**: - - Stored XSS in CMS content - - No Content Security Policy - - Missing output encoding in some areas - -3. **Information Disclosure**: - - Detailed error messages exposed - - Console.error statements with sensitive data - - No audit logging for security events - -### Infrastructure Security - -1. **File Upload Issues**: - - Path traversal vulnerability - - Weak MIME type validation - - No virus scanning - - Missing content validation - -2. **Network Security**: - - Missing security headers - - Permissive CORS policy - - No HTTPS enforcement - - Rate limiting can be bypassed - -## Recommended Fixes - -### Priority 1: Critical (Implement Immediately) - -1. **Remove Hardcoded Secrets** -```javascript -// Replace fallback with error -const secret = process.env.JWT_SECRET; -if (!secret) { - throw new Error('JWT_SECRET environment variable is required'); -} -``` - -2. **Fix SQL Injection** -```javascript -// Use parameterized queries -.whereRaw('timestamp >= datetime("now", ? || " days")', [`-${days}`]) -``` - -3. **Sanitize CMS Content** -```javascript -import DOMPurify from 'dompurify'; -dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(page.content) }} -``` - -### Priority 2: High (Implement Within 1 Week) - -1. **Add Token Type Validation** -```javascript -if (decoded.type !== 'admin') { - return res.status(401).json({ error: 'Invalid token type' }); -} -``` - -2. **Implement Security Headers** -```javascript -app.use(helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - scriptSrc: ["'self'", "'unsafe-inline'"], - styleSrc: ["'self'", "'unsafe-inline'"], - imgSrc: ["'self'", "data:", "https:"], - }, - }, -})); -``` - -3. **Fix Rate Limiting Bypass** -```javascript -// Check token validity before skipping rate limit -try { - const decoded = jwt.verify(token, process.env.JWT_SECRET); - return decoded && decoded.type === 'admin'; -} catch (err) { - return false; // Apply rate limiting on invalid tokens -} -``` - -### Priority 3: Medium (Implement Within 1 Month) - -1. **Enhance Password Security** - - Minimum 12 characters - - Complexity requirements - - Breach checking integration - -2. **Implement File Security** - - Content-based validation - - Path traversal protection - - Virus scanning - -3. **Add Security Monitoring** - - Audit logging - - Failed login tracking - - Anomaly detection - -## Security Checklist - -- [ ] Remove all hardcoded secrets -- [ ] Fix SQL injection vulnerabilities -- [ ] Add XSS protection (DOMPurify) -- [ ] Implement proper token validation -- [ ] Add all security headers -- [ ] Fix rate limiting bypass -- [ ] Enhance password requirements -- [ ] Add file upload security -- [ ] Implement audit logging -- [ ] Set up security monitoring -- [ ] Document security procedures -- [ ] Conduct penetration testing - -## Conclusion - -The PicPeak platform has significant security vulnerabilities that need immediate attention. The most critical issues are hardcoded secrets, SQL injection risks, and stored XSS vulnerabilities. While the codebase shows some security awareness (bcrypt hashing, JWT usage, input validation), the implementation has serious flaws that could lead to system compromise. - -**Recommended Action**: Address all critical vulnerabilities immediately before deploying to production. Consider a professional security audit after implementing these fixes. - ---- -*Generated by Claude Code Security Scanner* -*Scan completed: 2025-01-12* \ No newline at end of file diff --git a/frontend/.env.example b/frontend/.env.example index b8f3c08..7c0f34a 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -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 \ No newline at end of file +# 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 \ No newline at end of file diff --git a/frontend/.env.production.example b/frontend/.env.production.example index 11ca0a6..8640bbd 100644 --- a/frontend/.env.production.example +++ b/frontend/.env.production.example @@ -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 \ No newline at end of file +# 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 \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 35d0cd7..bacb5a2 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "picpeak-frontend", - "version": "1.0.61", + "version": "1.0.93", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "picpeak-frontend", - "version": "1.0.61", + "version": "1.0.93", "dependencies": { "@tanstack/react-query": "^5.0.0", "@tiptap/extension-character-count": "^2.26.1", @@ -22,7 +22,7 @@ "@types/react-google-recaptcha": "^2.1.9", "axios": "^1.3.2", "clsx": "^2.0.0", - "date-fns": "^2.29.3", + "date-fns": "4.1.0", "dompurify": "^3.2.6", "i18next": "^25.3.1", "i18next-browser-languagedetector": "^8.2.0", @@ -30,7 +30,7 @@ "js-cookie": "^3.0.5", "lodash": "^4.17.21", "lowlight": "^2.9.0", - "lucide-react": "^0.292.0", + "lucide-react": "0.525.0", "react": "^18.3.1", "react-countdown": "^2.3.5", "react-dom": "^18.3.1", @@ -39,7 +39,7 @@ "react-image-gallery": "^1.2.11", "react-intersection-observer": "^9.4.3", "react-router-dom": "^6.8.0", - "react-toastify": "^9.1.1", + "react-toastify": "11.0.5", "tailwind-merge": "^3.3.1" }, "devDependencies": { @@ -365,9 +365,9 @@ } }, "node_modules/@babel/types": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.0.tgz", - "integrity": "sha512-jYnje+JyZG5YThjHiF28oT4SIZLnYOcSBb6+SDaFIyzDVSkXQmQQYclJ2R+YxcdmK0AX6x1E5OQNtuh3jHDrUg==", + "version": "7.28.1", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.1.tgz", + "integrity": "sha512-x0LvFTekgSX+83TI28Y9wYPUfzrnl2aT5+5QLnO6v7mSJYtEEevuDRN0F0uSHRk1G1IWZC43o00Y0xDDrpBGPQ==", "dev": true, "license": "MIT", "dependencies": { @@ -379,9 +379,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", - "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz", + "integrity": "sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA==", "cpu": [ "ppc64" ], @@ -396,9 +396,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", - "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.8.tgz", + "integrity": "sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw==", "cpu": [ "arm" ], @@ -413,9 +413,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", - "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz", + "integrity": "sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w==", "cpu": [ "arm64" ], @@ -430,9 +430,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", - "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.8.tgz", + "integrity": "sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA==", "cpu": [ "x64" ], @@ -447,9 +447,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz", + "integrity": "sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw==", "cpu": [ "arm64" ], @@ -464,9 +464,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", - "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz", + "integrity": "sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg==", "cpu": [ "x64" ], @@ -481,9 +481,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", - "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz", + "integrity": "sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA==", "cpu": [ "arm64" ], @@ -498,9 +498,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", - "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz", + "integrity": "sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw==", "cpu": [ "x64" ], @@ -515,9 +515,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", - "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz", + "integrity": "sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg==", "cpu": [ "arm" ], @@ -532,9 +532,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", - "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz", + "integrity": "sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w==", "cpu": [ "arm64" ], @@ -549,9 +549,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", - "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz", + "integrity": "sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg==", "cpu": [ "ia32" ], @@ -566,9 +566,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", - "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz", + "integrity": "sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ==", "cpu": [ "loong64" ], @@ -583,9 +583,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", - "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz", + "integrity": "sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw==", "cpu": [ "mips64el" ], @@ -600,9 +600,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", - "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz", + "integrity": "sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ==", "cpu": [ "ppc64" ], @@ -617,9 +617,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", - "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz", + "integrity": "sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg==", "cpu": [ "riscv64" ], @@ -634,9 +634,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", - "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz", + "integrity": "sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg==", "cpu": [ "s390x" ], @@ -651,9 +651,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", - "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz", + "integrity": "sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ==", "cpu": [ "x64" ], @@ -668,9 +668,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", - "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz", + "integrity": "sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw==", "cpu": [ "arm64" ], @@ -685,9 +685,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", - "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz", + "integrity": "sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg==", "cpu": [ "x64" ], @@ -702,9 +702,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", - "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz", + "integrity": "sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ==", "cpu": [ "arm64" ], @@ -719,9 +719,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", - "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz", + "integrity": "sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ==", "cpu": [ "x64" ], @@ -735,10 +735,27 @@ "node": ">=18" } }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz", + "integrity": "sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", - "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz", + "integrity": "sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w==", "cpu": [ "x64" ], @@ -753,9 +770,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", - "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz", + "integrity": "sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ==", "cpu": [ "arm64" ], @@ -770,9 +787,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", - "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz", + "integrity": "sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg==", "cpu": [ "ia32" ], @@ -787,9 +804,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", - "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz", + "integrity": "sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw==", "cpu": [ "x64" ], @@ -871,9 +888,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -921,9 +938,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.30.1", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.30.1.tgz", - "integrity": "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg==", + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz", + "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==", "dev": true, "license": "MIT", "engines": { @@ -944,9 +961,9 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz", - "integrity": "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==", + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -957,19 +974,6 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1168,16 +1172,16 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.19", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz", - "integrity": "sha512-3FL3mnMbPu0muGOCaKAhhFEYmqv9eTfPSJRJmANrCwtgK8VuxpsZDGK+m0LYAGoyO8+0j5uRe4PeyPDK1yA/hA==", + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", "dev": true, "license": "MIT" }, "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.44.2.tgz", - "integrity": "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.45.1.tgz", + "integrity": "sha512-NEySIFvMY0ZQO+utJkgoMiCAjMrGvnbDLHvcmlA33UXJpYBCvlBEbMMtV837uCkS+plG2umfhn0T5mMAxGrlRA==", "cpu": [ "arm" ], @@ -1189,9 +1193,9 @@ ] }, "node_modules/@rollup/rollup-android-arm64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.44.2.tgz", - "integrity": "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.45.1.tgz", + "integrity": "sha512-ujQ+sMXJkg4LRJaYreaVx7Z/VMgBBd89wGS4qMrdtfUFZ+TSY5Rs9asgjitLwzeIbhwdEhyj29zhst3L1lKsRQ==", "cpu": [ "arm64" ], @@ -1203,9 +1207,9 @@ ] }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.44.2.tgz", - "integrity": "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.45.1.tgz", + "integrity": "sha512-FSncqHvqTm3lC6Y13xncsdOYfxGSLnP+73k815EfNmpewPs+EyM49haPS105Rh4aF5mJKywk9X0ogzLXZzN9lA==", "cpu": [ "arm64" ], @@ -1217,9 +1221,9 @@ ] }, "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.44.2.tgz", - "integrity": "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.45.1.tgz", + "integrity": "sha512-2/vVn/husP5XI7Fsf/RlhDaQJ7x9zjvC81anIVbr4b/f0xtSmXQTFcGIQ/B1cXIYM6h2nAhJkdMHTnD7OtQ9Og==", "cpu": [ "x64" ], @@ -1231,9 +1235,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.44.2.tgz", - "integrity": "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.45.1.tgz", + "integrity": "sha512-4g1kaDxQItZsrkVTdYQ0bxu4ZIQ32cotoQbmsAnW1jAE4XCMbcBPDirX5fyUzdhVCKgPcrwWuucI8yrVRBw2+g==", "cpu": [ "arm64" ], @@ -1245,9 +1249,9 @@ ] }, "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.44.2.tgz", - "integrity": "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.45.1.tgz", + "integrity": "sha512-L/6JsfiL74i3uK1Ti2ZFSNsp5NMiM4/kbbGEcOCps99aZx3g8SJMO1/9Y0n/qKlWZfn6sScf98lEOUe2mBvW9A==", "cpu": [ "x64" ], @@ -1259,9 +1263,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.44.2.tgz", - "integrity": "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.45.1.tgz", + "integrity": "sha512-RkdOTu2jK7brlu+ZwjMIZfdV2sSYHK2qR08FUWcIoqJC2eywHbXr0L8T/pONFwkGukQqERDheaGTeedG+rra6Q==", "cpu": [ "arm" ], @@ -1273,9 +1277,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.44.2.tgz", - "integrity": "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.45.1.tgz", + "integrity": "sha512-3kJ8pgfBt6CIIr1o+HQA7OZ9mp/zDk3ctekGl9qn/pRBgrRgfwiffaUmqioUGN9hv0OHv2gxmvdKOkARCtRb8Q==", "cpu": [ "arm" ], @@ -1287,9 +1291,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.44.2.tgz", - "integrity": "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.45.1.tgz", + "integrity": "sha512-k3dOKCfIVixWjG7OXTCOmDfJj3vbdhN0QYEqB+OuGArOChek22hn7Uy5A/gTDNAcCy5v2YcXRJ/Qcnm4/ma1xw==", "cpu": [ "arm64" ], @@ -1301,9 +1305,9 @@ ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.44.2.tgz", - "integrity": "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.45.1.tgz", + "integrity": "sha512-PmI1vxQetnM58ZmDFl9/Uk2lpBBby6B6rF4muJc65uZbxCs0EA7hhKCk2PKlmZKuyVSHAyIw3+/SiuMLxKxWog==", "cpu": [ "arm64" ], @@ -1315,9 +1319,9 @@ ] }, "node_modules/@rollup/rollup-linux-loongarch64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.44.2.tgz", - "integrity": "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.45.1.tgz", + "integrity": "sha512-9UmI0VzGmNJ28ibHW2GpE2nF0PBQqsyiS4kcJ5vK+wuwGnV5RlqdczVocDSUfGX/Na7/XINRVoUgJyFIgipoRg==", "cpu": [ "loong64" ], @@ -1329,9 +1333,9 @@ ] }, "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.44.2.tgz", - "integrity": "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.45.1.tgz", + "integrity": "sha512-7nR2KY8oEOUTD3pBAxIBBbZr0U7U+R9HDTPNy+5nVVHDXI4ikYniH1oxQz9VoB5PbBU1CZuDGHkLJkd3zLMWsg==", "cpu": [ "ppc64" ], @@ -1343,9 +1347,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.44.2.tgz", - "integrity": "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.45.1.tgz", + "integrity": "sha512-nlcl3jgUultKROfZijKjRQLUu9Ma0PeNv/VFHkZiKbXTBQXhpytS8CIj5/NfBeECZtY2FJQubm6ltIxm/ftxpw==", "cpu": [ "riscv64" ], @@ -1357,9 +1361,9 @@ ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.44.2.tgz", - "integrity": "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.45.1.tgz", + "integrity": "sha512-HJV65KLS51rW0VY6rvZkiieiBnurSzpzore1bMKAhunQiECPuxsROvyeaot/tcK3A3aGnI+qTHqisrpSgQrpgA==", "cpu": [ "riscv64" ], @@ -1371,9 +1375,9 @@ ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.44.2.tgz", - "integrity": "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.45.1.tgz", + "integrity": "sha512-NITBOCv3Qqc6hhwFt7jLV78VEO/il4YcBzoMGGNxznLgRQf43VQDae0aAzKiBeEPIxnDrACiMgbqjuihx08OOw==", "cpu": [ "s390x" ], @@ -1385,9 +1389,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.44.2.tgz", - "integrity": "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.45.1.tgz", + "integrity": "sha512-+E/lYl6qu1zqgPEnTrs4WysQtvc/Sh4fC2nByfFExqgYrqkKWp1tWIbe+ELhixnenSpBbLXNi6vbEEJ8M7fiHw==", "cpu": [ "x64" ], @@ -1399,9 +1403,9 @@ ] }, "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.44.2.tgz", - "integrity": "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.45.1.tgz", + "integrity": "sha512-a6WIAp89p3kpNoYStITT9RbTbTnqarU7D8N8F2CV+4Cl9fwCOZraLVuVFvlpsW0SbIiYtEnhCZBPLoNdRkjQFw==", "cpu": [ "x64" ], @@ -1413,9 +1417,9 @@ ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.44.2.tgz", - "integrity": "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.45.1.tgz", + "integrity": "sha512-T5Bi/NS3fQiJeYdGvRpTAP5P02kqSOpqiopwhj0uaXB6nzs5JVi2XMJb18JUSKhCOX8+UE1UKQufyD6Or48dJg==", "cpu": [ "arm64" ], @@ -1427,9 +1431,9 @@ ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.44.2.tgz", - "integrity": "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.45.1.tgz", + "integrity": "sha512-lxV2Pako3ujjuUe9jiU3/s7KSrDfH6IgTSQOnDWr9aJ92YsFd7EurmClK0ly/t8dzMkDtd04g60WX6yl0sGfdw==", "cpu": [ "ia32" ], @@ -1441,9 +1445,9 @@ ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.44.2.tgz", - "integrity": "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.45.1.tgz", + "integrity": "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==", "cpu": [ "x64" ], @@ -1455,9 +1459,9 @@ ] }, "node_modules/@tanstack/query-core": { - "version": "5.81.5", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.81.5.tgz", - "integrity": "sha512-ZJOgCy/z2qpZXWaj/oxvodDx07XcQa9BF92c0oINjHkoqUPsmm3uG08HpTaviviZ/N9eP1f9CM7mKSEkIo7O1Q==", + "version": "5.83.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.0.tgz", + "integrity": "sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==", "license": "MIT", "funding": { "type": "github", @@ -1465,12 +1469,12 @@ } }, "node_modules/@tanstack/react-query": { - "version": "5.81.5", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.81.5.tgz", - "integrity": "sha512-lOf2KqRRiYWpQT86eeeftAGnjuTR35myTP8MXyvHa81VlomoAWNEd8x5vkcAfQefu0qtYCvyqLropFZqgI2EQw==", + "version": "5.83.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.83.0.tgz", + "integrity": "sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==", "license": "MIT", "dependencies": { - "@tanstack/query-core": "5.81.5" + "@tanstack/query-core": "5.83.0" }, "funding": { "type": "github", @@ -1481,9 +1485,9 @@ } }, "node_modules/@tiptap/core": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.25.0.tgz", - "integrity": "sha512-pTLV0+g+SBL49/Y5A9ii7oHwlzIzpgroJVI3AcBk7/SeR7554ZzjxxtJmZkQ9/NxJO+k1jQp9grXaqqOLqC7cA==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.26.1.tgz", + "integrity": "sha512-fymyd/XZvYiHjBoLt1gxs024xP/LY26d43R1vluYq7AHBL/7DE3ywzy+1GEsGyAv5Je2L0KBhNIR/izbq3Kaqg==", "license": "MIT", "funding": { "type": "github", @@ -1494,9 +1498,9 @@ } }, "node_modules/@tiptap/extension-blockquote": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.25.0.tgz", - "integrity": "sha512-W+sVPlV9XmaNPUkxV2BinNEbk2hr4zw8VgKjqKQS9O0k2YIVRCfQch+4DudSAwBVMrVW97zVAKRNfictGFQ8vQ==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-2.26.1.tgz", + "integrity": "sha512-viQ6AHRhjCYYipKK6ZepBzwZpkuMvO9yhRHeUZDvlSOAh8rvsUTSre0y74nu8QRYUt4a44lJJ6BpphJK7bEgYA==", "license": "MIT", "funding": { "type": "github", @@ -1507,9 +1511,9 @@ } }, "node_modules/@tiptap/extension-bold": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.25.0.tgz", - "integrity": "sha512-3cBX2EtdFR3+EDTkIshhpQpXoZQbFUzxf6u86Qm0qD49JnVOjX9iexnUp8MydXPZA6NVsKeEfMhf18gV7oxTEw==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-2.26.1.tgz", + "integrity": "sha512-zCce9PRuTNhadFir71luLo99HERDpGJ0EEflGm7RN8I1SnNi9gD5ooK42BOIQtejGCJqg3hTPZiYDJC2hXvckQ==", "license": "MIT", "funding": { "type": "github", @@ -1520,9 +1524,9 @@ } }, "node_modules/@tiptap/extension-bubble-menu": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.25.0.tgz", - "integrity": "sha512-BnbfQWRXJDDy9/x/0Atu2Nka5ZAMyXLDFqzSLMAXqXSQcG6CZRTSNRgOCnjpda6Hq2yCtq7l/YEoXkbHT1ZZdQ==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-2.26.1.tgz", + "integrity": "sha512-oHevUcZbTMFOTpdCEo4YEDe044MB4P1ZrWyML8CGe5tnnKdlI9BN03AXpI1mEEa5CA3H1/eEckXx8EiCgYwQ3Q==", "license": "MIT", "dependencies": { "tippy.js": "^6.3.7" @@ -1537,9 +1541,9 @@ } }, "node_modules/@tiptap/extension-bullet-list": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.25.0.tgz", - "integrity": "sha512-KD+q/q6KIU2anedjtjG8vELkL5rYFdNHWc5XcUJgQoxbOCK3/sBuOgcn9mnFA2eAS6UkraN9Yx0BXEDbXX2HOw==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-2.26.1.tgz", + "integrity": "sha512-HHakuV4ckYCDOnBbne088FvCEP4YICw+wgPBz/V2dfpiFYQ4WzT0LPK9s7OFMCN+ROraoug+1ryN1Z1KdIgujQ==", "license": "MIT", "funding": { "type": "github", @@ -1564,9 +1568,9 @@ } }, "node_modules/@tiptap/extension-code": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.25.0.tgz", - "integrity": "sha512-rRp6X2aNNnvo7Fbqc3olZ0vLb52FlCPPfetr9gy6/M9uQdVYDhJcFOPuRuXtZ8M8X+WpCZBV29BvZFeDqfw8bw==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-2.26.1.tgz", + "integrity": "sha512-GU9deB1A/Tr4FMPu71CvlcjGKwRhGYz60wQ8m4aM+ELZcVIcZRa1ebR8bExRIEWnvRztQuyRiCQzw2N0xQJ1QQ==", "license": "MIT", "funding": { "type": "github", @@ -1577,9 +1581,9 @@ } }, "node_modules/@tiptap/extension-code-block": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.25.0.tgz", - "integrity": "sha512-T4kXbZNZ/NyklzQ/FWmUnjD4hgmJPrIBazzCZ/E/rF/Ag2IvUsztBT0PN3vTa+DAZ+IbM61TjlIpyJs1R7OdbQ==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-2.26.1.tgz", + "integrity": "sha512-/TDDOwONl0qEUc4+B6V9NnWtSjz95eg7/8uCb8Y8iRbGvI9vT4/znRKofFxstvKmW4URu/H74/g0ywV57h0B+A==", "license": "MIT", "funding": { "type": "github", @@ -1608,9 +1612,9 @@ } }, "node_modules/@tiptap/extension-document": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.25.0.tgz", - "integrity": "sha512-3gEZlQKUSIRrC6Az8QS7SJi4CvhMWrA7RBChM1aRl9vMNN8Ul7dZZk5StYJGPjL/koTiceMqx9pNmTCBprsbvQ==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-2.26.1.tgz", + "integrity": "sha512-2P2IZp1NRAE+21mRuFBiP3X2WKfZ6kUC23NJKpn8bcOamY3obYqCt0ltGPhE4eR8n8QAl2fI/3jIgjR07dC8ow==", "license": "MIT", "funding": { "type": "github", @@ -1621,9 +1625,9 @@ } }, "node_modules/@tiptap/extension-dropcursor": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.25.0.tgz", - "integrity": "sha512-eSHqp+iUI2mGVwvIyENP02hi5TSyQ+bdwNwIck6bdzjRvXakm72+8uPfVSLGxRKAQZ0RFtmux8ISazgUqF/oSw==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-2.26.1.tgz", + "integrity": "sha512-JkDQU2ZYFOuT5mNYb8OiWGwD1HcjbtmX8tLNugQbToECmz9WvVPqJmn7V/q8VGpP81iEECz/IsyRmuf2kSD4uA==", "license": "MIT", "funding": { "type": "github", @@ -1635,9 +1639,9 @@ } }, "node_modules/@tiptap/extension-floating-menu": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.25.0.tgz", - "integrity": "sha512-hPZ5SNpI14smTz4GpWQXTnxmeICINYiABSgXcsU5V66tik9OtxKwoCSR/gpU35esaAFUVRdjW7+sGkACLZD5AQ==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-2.26.1.tgz", + "integrity": "sha512-OJF+H6qhQogVTMedAGSWuoL1RPe3LZYXONuFCVyzHnvvMpK+BP1vm180E2zDNFnn/DVA+FOrzNGpZW7YjoFH1w==", "license": "MIT", "dependencies": { "tippy.js": "^6.3.7" @@ -1652,9 +1656,9 @@ } }, "node_modules/@tiptap/extension-gapcursor": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.25.0.tgz", - "integrity": "sha512-s/3WDbgkvLac88h5iYJLPJCDw8tMhlss1hk9GAo+zzP4h0xfazYie09KrA0CBdfaSOFyeJK3wedzjKZBtdgX4w==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-2.26.1.tgz", + "integrity": "sha512-KOiMZc3PwJS3hR0nSq5d0TJi2jkNZkLZElcT6pCEnhRHzPH6dRMu9GM5Jj798ZRUy0T9UFcKJalFZaDxnmRnpg==", "license": "MIT", "funding": { "type": "github", @@ -1679,9 +1683,9 @@ } }, "node_modules/@tiptap/extension-heading": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.25.0.tgz", - "integrity": "sha512-IrRKRRr7Bhpnq5aue1v5/e5N/eNdVV/THsgqqpLZO48pgN8Wv+TweOZe1Ntg/v8L4QSBC8iGMxxhiJZT8AzSkA==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-2.26.1.tgz", + "integrity": "sha512-KSzL8WZV3pjJG9ke4RaU70+B5UlYR2S6olNt5UCAawM+fi11mobVztiBoC19xtpSVqIXC1AmXOqUgnuSvmE4ZA==", "license": "MIT", "funding": { "type": "github", @@ -1692,9 +1696,9 @@ } }, "node_modules/@tiptap/extension-history": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.25.0.tgz", - "integrity": "sha512-y3uJkJv+UngDaDYfcVJ4kx8ivc3Etk5ow6N+47AMCRjUUweQ/CLiJwJ2C7nL7L82zOzVbb/NoR/B3UeE4ts/wQ==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-history/-/extension-history-2.26.1.tgz", + "integrity": "sha512-m6YR1gkkauIDo3PRl0gP+7Oc4n5OqDzcjVh6LvWREmZP8nmi94hfseYbqOXUb6RPHIc0JKF02eiRifT4MSd2nw==", "license": "MIT", "funding": { "type": "github", @@ -1706,9 +1710,9 @@ } }, "node_modules/@tiptap/extension-horizontal-rule": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.25.0.tgz", - "integrity": "sha512-bZovyhdOexB3Cv9ddUogWT+cd3KbnenMIZKhgrJ+R0J27rlOtzeUD9TeIjn4V8Of9mTxm3XDKUZGLgPiriN8Ww==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-2.26.1.tgz", + "integrity": "sha512-mT6baqOhs/NakgrAeDeed194E/ZJFGL692H0C7f1N7WDRaWxUu2oR0LrnRqSH5OyPjELkzu6nQnNy0+0tFGHHg==", "license": "MIT", "funding": { "type": "github", @@ -1720,9 +1724,9 @@ } }, "node_modules/@tiptap/extension-italic": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.25.0.tgz", - "integrity": "sha512-FZHmNqvWJ5SHYlUi+Qg3b2C0ZBt82DUDUqM+bqcQqSQu6B0c4IEc3+VHhjAJwEUIO9wX7xk/PsdM4Z5Ex4Lr3w==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-2.26.1.tgz", + "integrity": "sha512-pOs6oU4LyGO89IrYE4jbE8ZYsPwMMIiKkYfXcfeD9NtpGNBnjeVXXF5I9ndY2ANrCAgC8k58C3/powDRf0T2yA==", "license": "MIT", "funding": { "type": "github", @@ -1733,9 +1737,9 @@ } }, "node_modules/@tiptap/extension-link": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.25.0.tgz", - "integrity": "sha512-jNd+1Fd7wiIbxlS51weBzyDtBEBSVzW0cgzdwOzBYQtPJueRyXNNVERksyinDuVgcfvEWgmNZUylgzu7mehnEg==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-2.26.1.tgz", + "integrity": "sha512-7yfum5Jymkue/uOSTQPt2SmkZIdZx7t3QhZLqBU7R9ettkdSCBgEGok6N+scJM1R1Zes+maSckLm0JZw5BKYNA==", "license": "MIT", "dependencies": { "linkifyjs": "^4.2.0" @@ -1750,9 +1754,9 @@ } }, "node_modules/@tiptap/extension-list-item": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.25.0.tgz", - "integrity": "sha512-HLstO/R+dNjIFMXN15bANc8i/+CDpEgtEQhZNHqvSUJH9xQ5op0S05m5VvFI10qnwXNjwwXdhxUYwwjIDCiAgg==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-2.26.1.tgz", + "integrity": "sha512-quOXckC73Luc3x+Dcm88YAEBW+Crh3x5uvtQOQtn2GEG91AshrvbnhGRiYnfvEN7UhWIS+FYI5liHFcRKSUKrQ==", "license": "MIT", "funding": { "type": "github", @@ -1763,9 +1767,9 @@ } }, "node_modules/@tiptap/extension-ordered-list": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.25.0.tgz", - "integrity": "sha512-Hlid16nQdDFOGOx6mJT+zPEae2t1dGlJ18pqCqaVMuDnIpNIWmQutJk5QYxGVxr9awd2SpHTpQtdBTqcufbHtw==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-2.26.1.tgz", + "integrity": "sha512-UHKNRxq6TBnXMGFSq91knD6QaHsyyOwLOsXMzupmKM5Su0s+CRXEjfav3qKlbb9e4m7D7S/a0aPm8nC9KIXNhQ==", "license": "MIT", "funding": { "type": "github", @@ -1776,9 +1780,9 @@ } }, "node_modules/@tiptap/extension-paragraph": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.25.0.tgz", - "integrity": "sha512-53gpWMPedkWVDp3u/1sLt6vnr3BWz4vArGCmmabLucCI2Yl4R6S/AQ9yj/+jOHvWbXCroCbKtmmwxJl32uGN2w==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-2.26.1.tgz", + "integrity": "sha512-UezvM9VDRAVJlX1tykgHWSD1g3MKfVMWWZ+Tg+PE4+kizOwoYkRWznVPgCAxjmyHajxpCKRXgqTZkOxjJ9Kjzg==", "license": "MIT", "funding": { "type": "github", @@ -1803,9 +1807,9 @@ } }, "node_modules/@tiptap/extension-strike": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.25.0.tgz", - "integrity": "sha512-Z5YBKnv4N6MMD1LEo9XbmWnmdXavZKOOJt/OkXYFZ3KgzB52Z3q3DDfH+NyeCtKKSWqWVxbBHKLnsojDerSf2g==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-2.26.1.tgz", + "integrity": "sha512-CkoRH+pAi6MgdCh7K0cVZl4N2uR4pZdabXAnFSoLZRSg6imLvEUmWHfSi1dl3Z7JOvd3a4yZ4NxerQn5MWbJ7g==", "license": "MIT", "funding": { "type": "github", @@ -1816,9 +1820,9 @@ } }, "node_modules/@tiptap/extension-text": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.25.0.tgz", - "integrity": "sha512-HlZL86rihpP/R8+dqRrvzSRmiPpx6ctlAKM9PnWT/WRMeI4Y1AUq6PSHLz74wtYO1LH4PXys1ws3n+pLP4Mo6g==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-2.26.1.tgz", + "integrity": "sha512-p2n8WVMd/2vckdJlol24acaTDIZAhI7qle5cM75bn01sOEZoFlSw6SwINOULrUCzNJsYb43qrLEibZb4j2LeQw==", "license": "MIT", "funding": { "type": "github", @@ -1842,9 +1846,9 @@ } }, "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", - "integrity": "sha512-MKAXqDATEbuFEB1SeeAFy2VbefUMJ9jxQyybpaHjDX+Ik0Ddu+aYuJP/njvLuejXCqhrkS/AorxzmHUC4HNPbQ==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-2.26.1.tgz", + "integrity": "sha512-t9Nc/UkrbCfnSHEUi1gvUQ2ZPzvfdYFT5TExoV2DTiUCkhG6+mecT5bTVFGW3QkPmbToL+nFhGn4ZRMDD0SP3Q==", "license": "MIT", "funding": { "type": "github", @@ -1855,9 +1859,9 @@ } }, "node_modules/@tiptap/pm": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.25.0.tgz", - "integrity": "sha512-vuzU0pLGQyHqtikAssHn9V61aXLSQERQtn3MUtaJ36fScQg7RClAK5gnIbBt3Ul3VFof8o4xYmcidARc0X/E5A==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-2.26.1.tgz", + "integrity": "sha512-8aF+mY/vSHbGFqyG663ds84b+vca5Lge3tHdTMTKazxCnhXR9dn2oQJMnZ78YZvdRbkPkMJJHti9h3K7u2UQvw==", "license": "MIT", "dependencies": { "prosemirror-changeset": "^2.3.0", @@ -1885,13 +1889,13 @@ } }, "node_modules/@tiptap/react": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.25.0.tgz", - "integrity": "sha512-Fc7uj/+goEhvJkH2vYJxXLH1GsUkOcsIR3kUyL0vejNRvpzzd87CI/EiSD2ESJO43czQcsJkiYzY4EC+p8NF9w==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/react/-/react-2.26.1.tgz", + "integrity": "sha512-Zxlwzi1iML7aELa+PyysFD2ncVo2mEcjTkhoDok9iTbMGpm1oU8hgR1i6iHrcSNQLfaRiW6M7HNhZZQPKIC9yw==", "license": "MIT", "dependencies": { - "@tiptap/extension-bubble-menu": "^2.25.0", - "@tiptap/extension-floating-menu": "^2.25.0", + "@tiptap/extension-bubble-menu": "^2.26.1", + "@tiptap/extension-floating-menu": "^2.26.1", "@types/use-sync-external-store": "^0.0.6", "fast-deep-equal": "^3", "use-sync-external-store": "^1" @@ -1908,32 +1912,32 @@ } }, "node_modules/@tiptap/starter-kit": { - "version": "2.25.0", - "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.25.0.tgz", - "integrity": "sha512-MWt6gEdQ2LPuCqbvNGmS0uA+6rtMGRh3vC0WBNp6rJPAvwS8OPcpraLz61cWjgzeKZBUKODpNA5IZ6gDRyH9LQ==", + "version": "2.26.1", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-2.26.1.tgz", + "integrity": "sha512-oziMGCds8SVQ3s5dRpBxVdEKZAmO/O//BjZ69mhA3q4vJdR0rnfLb5fTxSeQvHiqB878HBNn76kNaJrHrV35GA==", "license": "MIT", "dependencies": { - "@tiptap/core": "^2.25.0", - "@tiptap/extension-blockquote": "^2.25.0", - "@tiptap/extension-bold": "^2.25.0", - "@tiptap/extension-bullet-list": "^2.25.0", - "@tiptap/extension-code": "^2.25.0", - "@tiptap/extension-code-block": "^2.25.0", - "@tiptap/extension-document": "^2.25.0", - "@tiptap/extension-dropcursor": "^2.25.0", - "@tiptap/extension-gapcursor": "^2.25.0", - "@tiptap/extension-hard-break": "^2.25.0", - "@tiptap/extension-heading": "^2.25.0", - "@tiptap/extension-history": "^2.25.0", - "@tiptap/extension-horizontal-rule": "^2.25.0", - "@tiptap/extension-italic": "^2.25.0", - "@tiptap/extension-list-item": "^2.25.0", - "@tiptap/extension-ordered-list": "^2.25.0", - "@tiptap/extension-paragraph": "^2.25.0", - "@tiptap/extension-strike": "^2.25.0", - "@tiptap/extension-text": "^2.25.0", - "@tiptap/extension-text-style": "^2.25.0", - "@tiptap/pm": "^2.25.0" + "@tiptap/core": "^2.26.1", + "@tiptap/extension-blockquote": "^2.26.1", + "@tiptap/extension-bold": "^2.26.1", + "@tiptap/extension-bullet-list": "^2.26.1", + "@tiptap/extension-code": "^2.26.1", + "@tiptap/extension-code-block": "^2.26.1", + "@tiptap/extension-document": "^2.26.1", + "@tiptap/extension-dropcursor": "^2.26.1", + "@tiptap/extension-gapcursor": "^2.26.1", + "@tiptap/extension-hard-break": "^2.26.1", + "@tiptap/extension-heading": "^2.26.1", + "@tiptap/extension-history": "^2.26.1", + "@tiptap/extension-horizontal-rule": "^2.26.1", + "@tiptap/extension-italic": "^2.26.1", + "@tiptap/extension-list-item": "^2.26.1", + "@tiptap/extension-ordered-list": "^2.26.1", + "@tiptap/extension-paragraph": "^2.26.1", + "@tiptap/extension-strike": "^2.26.1", + "@tiptap/extension-text": "^2.26.1", + "@tiptap/extension-text-style": "^2.26.1", + "@tiptap/pm": "^2.26.1" }, "funding": { "type": "github", @@ -2106,17 +2110,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.35.1.tgz", - "integrity": "sha512-9XNTlo7P7RJxbVeICaIIIEipqxLKguyh+3UbXuT2XQuFp6d8VOeDEGuz5IiX0dgZo8CiI6aOFLg4e8cF71SFVg==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", + "integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.35.1", - "@typescript-eslint/type-utils": "8.35.1", - "@typescript-eslint/utils": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/type-utils": "8.38.0", + "@typescript-eslint/utils": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -2130,7 +2134,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.35.1", + "@typescript-eslint/parser": "^8.38.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } @@ -2146,16 +2150,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.35.1.tgz", - "integrity": "sha512-3MyiDfrfLeK06bi/g9DqJxP5pV74LNv4rFTyvGDmT3x2p1yp1lOd+qYZfiRPIOf/oON+WRZR5wxxuF85qOar+w==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz", + "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.35.1", - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/typescript-estree": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4" }, "engines": { @@ -2171,14 +2175,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.35.1.tgz", - "integrity": "sha512-VYxn/5LOpVxADAuP3NrnxxHYfzVtQzLKeldIhDhzC8UHaiQvYlXvKuVho1qLduFbJjjy5U5bkGwa3rUGUb1Q6Q==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", + "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.35.1", - "@typescript-eslint/types": "^8.35.1", + "@typescript-eslint/tsconfig-utils": "^8.38.0", + "@typescript-eslint/types": "^8.38.0", "debug": "^4.3.4" }, "engines": { @@ -2193,14 +2197,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.35.1.tgz", - "integrity": "sha512-s/Bpd4i7ht2934nG+UoSPlYXd08KYz3bmjLEb7Ye1UVob0d1ENiT3lY8bsCmik4RqfSbPw9xJJHbugpPpP5JUg==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz", + "integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1" + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2211,9 +2215,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.35.1.tgz", - "integrity": "sha512-K5/U9VmT9dTHoNowWZpz+/TObS3xqC5h0xAIjXPw+MNcKV9qg6eSatEnmeAwkjHijhACH0/N7bkhKvbt1+DXWQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", + "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", "dev": true, "license": "MIT", "engines": { @@ -2228,14 +2232,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.35.1.tgz", - "integrity": "sha512-HOrUBlfVRz5W2LIKpXzZoy6VTZzMu2n8q9C2V/cFngIC5U1nStJgv0tMV4sZPzdf4wQm9/ToWUFPMN9Vq9VJQQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", + "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.35.1", - "@typescript-eslint/utils": "8.35.1", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/utils": "8.38.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -2252,9 +2257,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.35.1.tgz", - "integrity": "sha512-q/O04vVnKHfrrhNAscndAn1tuQhIkwqnaW+eu5waD5IPts2eX1dgJxgqcPx5BX109/qAz7IG6VrEPTOYKCNfRQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", + "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", "dev": true, "license": "MIT", "engines": { @@ -2266,16 +2271,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.35.1.tgz", - "integrity": "sha512-Vvpuvj4tBxIka7cPs6Y1uvM7gJgdF5Uu9F+mBJBPY4MhvjrjWGK4H0lVgLJd/8PWZ23FTqsaJaLEkBCFUk8Y9g==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", + "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.35.1", - "@typescript-eslint/tsconfig-utils": "8.35.1", - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/visitor-keys": "8.35.1", + "@typescript-eslint/project-service": "8.38.0", + "@typescript-eslint/tsconfig-utils": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -2334,16 +2339,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.35.1.tgz", - "integrity": "sha512-lhnwatFmOFcazAsUm3ZnZFpXSxiwoa1Lj50HphnDe1Et01NF4+hrdXONSUHIcbVu2eFb1bAf+5yjXkGVkXBKAQ==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", + "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.35.1", - "@typescript-eslint/types": "8.35.1", - "@typescript-eslint/typescript-estree": "8.35.1" + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -2358,13 +2363,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.35.1.tgz", - "integrity": "sha512-VRwixir4zBWCSTP/ljEo091lbpypz57PoeAQ9imjG+vbeof9LplljsL1mos4ccG6H9IjfrVGM359RozUnuFhpw==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", + "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.35.1", + "@typescript-eslint/types": "8.38.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -2376,16 +2381,16 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.6.0.tgz", - "integrity": "sha512-5Kgff+m8e2PB+9j51eGHEpn5kUzRKH2Ry0qGoe8ItJg7pqnkPrYPkDQZGgGmTa0EGarHrkjLvOdU3b1fzI8otQ==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.27.4", + "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-beta.19", + "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, @@ -2393,7 +2398,7 @@ "node": "^14.18.0 || >=16.0.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/acorn": { @@ -2855,19 +2860,13 @@ "license": "MIT" }, "node_modules/date-fns": { - "version": "2.30.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", - "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.21.0" - }, - "engines": { - "node": ">=0.11" - }, "funding": { - "type": "opencollective", - "url": "https://opencollective.com/date-fns" + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" } }, "node_modules/debug": { @@ -2949,9 +2948,9 @@ "license": "MIT" }, "node_modules/electron-to-chromium": { - "version": "1.5.179", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.179.tgz", - "integrity": "sha512-UWKi/EbBopgfFsc5k61wFpV7WrnnSlSzW/e2XcBmS6qKYTivZlLtoll5/rdqRTxGglGHkmkW0j0pFNJG10EUIQ==", + "version": "1.5.189", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.189.tgz", + "integrity": "sha512-y9D1ntS1ruO/pZ/V2FtLE+JXLQe28XoRpZ7QCCo0T8LdQladzdcOVQZH/IWLVJvCw12OGMb6hYOeOAjntCmJRQ==", "dev": true, "license": "ISC" }, @@ -3020,9 +3019,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "version": "0.25.8", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.8.tgz", + "integrity": "sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -3033,31 +3032,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" + "@esbuild/aix-ppc64": "0.25.8", + "@esbuild/android-arm": "0.25.8", + "@esbuild/android-arm64": "0.25.8", + "@esbuild/android-x64": "0.25.8", + "@esbuild/darwin-arm64": "0.25.8", + "@esbuild/darwin-x64": "0.25.8", + "@esbuild/freebsd-arm64": "0.25.8", + "@esbuild/freebsd-x64": "0.25.8", + "@esbuild/linux-arm": "0.25.8", + "@esbuild/linux-arm64": "0.25.8", + "@esbuild/linux-ia32": "0.25.8", + "@esbuild/linux-loong64": "0.25.8", + "@esbuild/linux-mips64el": "0.25.8", + "@esbuild/linux-ppc64": "0.25.8", + "@esbuild/linux-riscv64": "0.25.8", + "@esbuild/linux-s390x": "0.25.8", + "@esbuild/linux-x64": "0.25.8", + "@esbuild/netbsd-arm64": "0.25.8", + "@esbuild/netbsd-x64": "0.25.8", + "@esbuild/openbsd-arm64": "0.25.8", + "@esbuild/openbsd-x64": "0.25.8", + "@esbuild/openharmony-arm64": "0.25.8", + "@esbuild/sunos-x64": "0.25.8", + "@esbuild/win32-arm64": "0.25.8", + "@esbuild/win32-ia32": "0.25.8", + "@esbuild/win32-x64": "0.25.8" } }, "node_modules/escalade": { @@ -3083,9 +3083,9 @@ } }, "node_modules/eslint": { - "version": "9.30.1", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.30.1.tgz", - "integrity": "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ==", + "version": "9.31.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz", + "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3093,9 +3093,9 @@ "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.14.0", + "@eslint/core": "^0.15.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.30.1", + "@eslint/js": "9.31.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -3435,9 +3435,9 @@ } }, "node_modules/form-data": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.3.tgz", - "integrity": "sha512-qsITQPfmvMOSAdeyZ+12I1c+CKSstAFAwu+97zrnWAbIr5u8wfsExUzCesVLC8NgHuRUqNN4Zy6UPWUTRGslcA==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -3713,9 +3713,9 @@ } }, "node_modules/i18next": { - "version": "25.3.1", - "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.1.tgz", - "integrity": "sha512-S4CPAx8LfMOnURnnJa8jFWvur+UX/LWcl6+61p9VV7SK2m0445JeBJ6tLD0D5SR0H29G4PYfWkEhivKG5p4RDg==", + "version": "25.3.2", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.3.2.tgz", + "integrity": "sha512-JSnbZDxRVbphc5jiptxr3o2zocy5dEqpVm9qCGdJwRNO+9saUJS0/u4LnM/13C23fUEWxAylPqKU/NpMV/IjqA==", "funding": [ { "type": "individual", @@ -4113,12 +4113,12 @@ } }, "node_modules/lucide-react": { - "version": "0.292.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.292.0.tgz", - "integrity": "sha512-rRgUkpEHWpa5VCT66YscInCQmQuPCB1RFRzkkxMxg4b+jaL0V12E3riWWR2Sh5OIiUhCwGW/ZExuEO4Az32E6Q==", + "version": "0.525.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.525.0.tgz", + "integrity": "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ==", "license": "ISC", "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/markdown-it": { @@ -4778,9 +4778,9 @@ } }, "node_modules/prosemirror-model": { - "version": "1.25.1", - "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.1.tgz", - "integrity": "sha512-AUvbm7qqmpZa5d9fPKMvH1Q5bqYQvAZWOGRvxsB6iFLyycvC9MwNemNVjHVrWgjaoxAfY8XVg7DbvQ/qxvI9Eg==", + "version": "1.25.2", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.2.tgz", + "integrity": "sha512-BVypCAJ4SL6jOiTsDffP3Wp6wD69lRhI4zg/iT8JXjp3ccZFiq5WyguxvMKmdKFC3prhaig7wSr8dneDToHE1Q==", "license": "MIT", "dependencies": { "orderedmap": "^2.0.0" @@ -4855,9 +4855,9 @@ } }, "node_modules/prosemirror-view": { - "version": "1.40.0", - "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.40.0.tgz", - "integrity": "sha512-2G3svX0Cr1sJjkD/DYWSe3cfV5VPVTBOxI9XQEGWJDFEpsZb/gh4MV29ctv+OJx2RFX4BLt09i+6zaGM/ldkCw==", + "version": "1.40.1", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.40.1.tgz", + "integrity": "sha512-pbwUjt3G7TlsQQHDiYSupWBhJswpLVB09xXm1YiJPdkjkh9Pe7Y51XdLh5VWIZmROLY8UpUpG03lkdhm9lzIBA==", "license": "MIT", "dependencies": { "prosemirror-model": "^1.20.0", @@ -5074,25 +5074,16 @@ } }, "node_modules/react-toastify": { - "version": "9.1.3", - "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-9.1.3.tgz", - "integrity": "sha512-fPfb8ghtn/XMxw3LkxQBk3IyagNpF/LIKjOBflbexr2AWxAH1MJgvnESwEwBn9liLFXgTKWgBSdZpw9m4OTHTg==", + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/react-toastify/-/react-toastify-11.0.5.tgz", + "integrity": "sha512-EpqHBGvnSTtHYhCPLxML05NLY2ZX0JURbAdNYa6BUkk+amz4wbKBQvoKQAB0ardvSarUBuY4Q4s1sluAzZwkmA==", "license": "MIT", "dependencies": { - "clsx": "^1.1.1" + "clsx": "^2.1.1" }, "peerDependencies": { - "react": ">=16", - "react-dom": ">=16" - } - }, - "node_modules/react-toastify/node_modules/clsx": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", - "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", - "license": "MIT", - "engines": { - "node": ">=6" + "react": "^18 || ^19", + "react-dom": "^18 || ^19" } }, "node_modules/read-cache": { @@ -5161,9 +5152,9 @@ } }, "node_modules/rollup": { - "version": "4.44.2", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.44.2.tgz", - "integrity": "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg==", + "version": "4.45.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.45.1.tgz", + "integrity": "sha512-4iya7Jb76fVpQyLoiVpzUrsjQ12r3dM7fIVz+4NwoYvZOShknRmiv+iu9CClZml5ZLGb0XMcYLutK6w9tgxHDw==", "dev": true, "license": "MIT", "dependencies": { @@ -5177,26 +5168,26 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.44.2", - "@rollup/rollup-android-arm64": "4.44.2", - "@rollup/rollup-darwin-arm64": "4.44.2", - "@rollup/rollup-darwin-x64": "4.44.2", - "@rollup/rollup-freebsd-arm64": "4.44.2", - "@rollup/rollup-freebsd-x64": "4.44.2", - "@rollup/rollup-linux-arm-gnueabihf": "4.44.2", - "@rollup/rollup-linux-arm-musleabihf": "4.44.2", - "@rollup/rollup-linux-arm64-gnu": "4.44.2", - "@rollup/rollup-linux-arm64-musl": "4.44.2", - "@rollup/rollup-linux-loongarch64-gnu": "4.44.2", - "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2", - "@rollup/rollup-linux-riscv64-gnu": "4.44.2", - "@rollup/rollup-linux-riscv64-musl": "4.44.2", - "@rollup/rollup-linux-s390x-gnu": "4.44.2", - "@rollup/rollup-linux-x64-gnu": "4.44.2", - "@rollup/rollup-linux-x64-musl": "4.44.2", - "@rollup/rollup-win32-arm64-msvc": "4.44.2", - "@rollup/rollup-win32-ia32-msvc": "4.44.2", - "@rollup/rollup-win32-x64-msvc": "4.44.2", + "@rollup/rollup-android-arm-eabi": "4.45.1", + "@rollup/rollup-android-arm64": "4.45.1", + "@rollup/rollup-darwin-arm64": "4.45.1", + "@rollup/rollup-darwin-x64": "4.45.1", + "@rollup/rollup-freebsd-arm64": "4.45.1", + "@rollup/rollup-freebsd-x64": "4.45.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.45.1", + "@rollup/rollup-linux-arm-musleabihf": "4.45.1", + "@rollup/rollup-linux-arm64-gnu": "4.45.1", + "@rollup/rollup-linux-arm64-musl": "4.45.1", + "@rollup/rollup-linux-loongarch64-gnu": "4.45.1", + "@rollup/rollup-linux-powerpc64le-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-gnu": "4.45.1", + "@rollup/rollup-linux-riscv64-musl": "4.45.1", + "@rollup/rollup-linux-s390x-gnu": "4.45.1", + "@rollup/rollup-linux-x64-gnu": "4.45.1", + "@rollup/rollup-linux-x64-musl": "4.45.1", + "@rollup/rollup-win32-arm64-msvc": "4.45.1", + "@rollup/rollup-win32-ia32-msvc": "4.45.1", + "@rollup/rollup-win32-x64-msvc": "4.45.1", "fsevents": "~2.3.2" } }, @@ -5565,9 +5556,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { @@ -5653,15 +5644,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.35.1", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.35.1.tgz", - "integrity": "sha512-xslJjFzhOmHYQzSB/QTeASAHbjmxOGEP6Coh93TXmUBFQoJ1VU35UHIDmG06Jd6taf3wqqC1ntBnCMeymy5Ovw==", + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.38.0.tgz", + "integrity": "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.35.1", - "@typescript-eslint/parser": "8.35.1", - "@typescript-eslint/utils": "8.35.1" + "@typescript-eslint/eslint-plugin": "8.38.0", + "@typescript-eslint/parser": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/utils": "8.38.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5739,9 +5731,9 @@ "license": "MIT" }, "node_modules/vite": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.2.tgz", - "integrity": "sha512-hxdyZDY1CM6SNpKI4w4lcUc3Mtkd9ej4ECWVHSMrOdSinVc2zYOAppHeGc/hzmRo3pxM5blMzkuWHOJA/3NiFw==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.5.tgz", + "integrity": "sha512-1mncVwJxy2C9ThLwz0+2GKZyEXuC3MyWtAAlNftlZZXZDP3AJt5FmwcMit/IGGaNZ8ZOB2BNO/HFUB+CpN0NQw==", "dev": true, "license": "MIT", "dependencies": { @@ -5829,9 +5821,9 @@ } }, "node_modules/vite/node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", "engines": { diff --git a/frontend/package.json b/frontend/package.json index 00e423b..1eb10bd 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "picpeak-frontend", "private": true, - "version": "1.0.61", + "version": "1.0.93", "type": "module", "scripts": { "dev": "vite", @@ -25,7 +25,7 @@ "@types/react-google-recaptcha": "^2.1.9", "axios": "^1.3.2", "clsx": "^2.0.0", - "date-fns": "^2.29.3", + "date-fns": "4.1.0", "dompurify": "^3.2.6", "i18next": "^25.3.1", "i18next-browser-languagedetector": "^8.2.0", @@ -33,7 +33,7 @@ "js-cookie": "^3.0.5", "lodash": "^4.17.21", "lowlight": "^2.9.0", - "lucide-react": "^0.292.0", + "lucide-react": "0.525.0", "react": "^18.3.1", "react-countdown": "^2.3.5", "react-dom": "^18.3.1", @@ -42,7 +42,7 @@ "react-image-gallery": "^1.2.11", "react-intersection-observer": "^9.4.3", "react-router-dom": "^6.8.0", - "react-toastify": "^9.1.1", + "react-toastify": "11.0.5", "tailwind-merge": "^3.3.1" }, "devDependencies": { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cd0f254..04cd202 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -16,12 +16,14 @@ import { EventsListPage, CreateEventPageEnhanced as CreateEventPage, EventDetailsPage, + EventFeedbackPage, EmailConfigPage, ArchivesPage, AnalyticsPage, BrandingPage, SettingsPage, - CMSPage + CMSPage, + BackupManagement } from './pages/admin'; import { CMSPageEnhanced } from './pages/admin/CMSPageEnhanced'; import { AdminLayout, AdminAuthWrapper } from './components/admin'; @@ -44,17 +46,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, @@ -62,9 +73,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, @@ -105,11 +121,13 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> } /> } /> + } /> } /> } /> diff --git a/frontend/src/components/admin/AdminPhotoGrid.tsx b/frontend/src/components/admin/AdminPhotoGrid.tsx index e627a3f..eaad59a 100644 --- a/frontend/src/components/admin/AdminPhotoGrid.tsx +++ b/frontend/src/components/admin/AdminPhotoGrid.tsx @@ -23,7 +23,7 @@ export const AdminPhotoGrid: React.FC = ({ const [selectedPhotos, setSelectedPhotos] = useState>(new Set()); const [isSelectionMode, setIsSelectionMode] = useState(false); const [isDeleting, setIsDeleting] = useState(false); - const [deletingPhotoId, setDeletingPhotoId] = useState(null); + const [deletingPhotos, setDeletingPhotos] = useState>(new Set()); const handlePhotoSelect = (photoId: number, e?: React.MouseEvent) => { if (e) { @@ -54,15 +54,18 @@ export const AdminPhotoGrid: React.FC = ({ return; } - setDeletingPhotoId(photo.id); + setDeletingPhotos(prev => new Set(prev).add(photo.id)); try { await photosService.deletePhoto(eventId, photo.id); toast.success('Photo deleted successfully'); onPhotosDeleted(); } catch (error) { toast.error('Failed to delete photo'); - } finally { - setDeletingPhotoId(null); + setDeletingPhotos(prev => { + const newSet = new Set(prev); + newSet.delete(photo.id); + return newSet; + }); } }; @@ -75,14 +78,18 @@ export const AdminPhotoGrid: React.FC = ({ } setIsDeleting(true); + const selectedIds = Array.from(selectedPhotos); + setDeletingPhotos(new Set(selectedIds)); + try { - await photosService.deletePhotos(eventId, Array.from(selectedPhotos)); + await photosService.deletePhotos(eventId, selectedIds); toast.success(`${count} photo${count > 1 ? 's' : ''} deleted successfully`); setSelectedPhotos(new Set()); setIsSelectionMode(false); onPhotosDeleted(); } catch (error) { toast.error('Failed to delete photos'); + setDeletingPhotos(new Set()); } finally { setIsDeleting(false); } @@ -155,13 +162,15 @@ export const AdminPhotoGrid: React.FC = ({ {/* Photo Grid */}
- {photos.map((photo, index) => ( -
isSelectionMode ? handlePhotoSelect(photo.id) : onPhotoClick(photo, index)} + {photos.map((photo, index) => { + const isDeleting = deletingPhotos.has(photo.id); + return ( +
!isDeleting && (isSelectionMode ? handlePhotoSelect(photo.id) : onPhotoClick(photo, index))} > {/* Selection Checkbox */} {isSelectionMode && ( @@ -219,8 +228,8 @@ export const AdminPhotoGrid: React.FC = ({ @@ -238,7 +247,8 @@ export const AdminPhotoGrid: React.FC = ({
)}
- ))} + ); + })}
{photos.length === 0 && ( diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 4b1f136..f638951 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -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 }, ]; diff --git a/frontend/src/components/admin/BackupConfiguration.jsx b/frontend/src/components/admin/BackupConfiguration.jsx new file mode 100644 index 0000000..97200f8 --- /dev/null +++ b/frontend/src/components/admin/BackupConfiguration.jsx @@ -0,0 +1,611 @@ +import React, { useState, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + Save, + Server, + Cloud, + HardDrive, + Clock, + Calendar, + Shield, + AlertCircle, + Info, + Eye, + EyeOff, + Wifi, + CheckCircle, + XCircle, + Loader2, + FolderOpen, + Database, + Image, + FileArchive +} from 'lucide-react'; +import { toast } from 'react-toastify'; +import { Button, Card, Input } from '../common'; + +export const BackupConfiguration = ({ config, onSave, isSaving }) => { + const { t } = useTranslation(); + + const destinationTypes = [ + { + id: 'local', + name: t('backup.configuration.destinationTypes.local.name'), + icon: HardDrive, + description: t('backup.configuration.destinationTypes.local.description'), + fields: ['backup_destination_path'] + }, + { + id: 'rsync', + name: t('backup.configuration.destinationTypes.rsync.name'), + icon: Server, + description: t('backup.configuration.destinationTypes.rsync.description'), + fields: ['backup_rsync_host', 'backup_rsync_user', 'backup_rsync_path', 'backup_rsync_ssh_key'] + }, + { + id: 's3', + name: t('backup.configuration.destinationTypes.s3.name'), + icon: Cloud, + description: t('backup.configuration.destinationTypes.s3.description'), + fields: ['backup_s3_endpoint', 'backup_s3_bucket', 'backup_s3_access_key', 'backup_s3_secret_key', 'backup_s3_region'] + } + ]; + + const scheduleOptions = [ + { value: 'hourly', label: t('backup.configuration.schedule.options.hourly') }, + { value: 'daily', label: t('backup.configuration.schedule.options.daily') }, + { value: 'weekly', label: t('backup.configuration.schedule.options.weekly') }, + { value: 'custom', label: t('backup.configuration.schedule.options.custom') } + ]; + + 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(t('backup.configuration.messages.requiredFields')); + return; + } + + onSave(formData); + }; + + const testConnection = async () => { + setTestingConnection(true); + try { + // TODO: Implement connection test endpoint + await new Promise(resolve => setTimeout(resolve, 2000)); + toast.success(t('backup.configuration.messages.connectionSuccess')); + } catch (error) { + toast.error(t('backup.configuration.messages.connectionFailed') + ': ' + error.message); + } finally { + setTestingConnection(false); + } + }; + + const selectedDestination = destinationTypes.find(t => t.id === formData.backup_destination_type); + + return ( +
+ {/* Enable/Disable Toggle */} + +
+
+

{t('backup.configuration.enableBackup')}

+

+ {t('backup.configuration.enableBackupHelp')} +

+
+ +
+
+ + {/* Destination Configuration */} + +

{t('backup.configuration.destinationType')}

+ + {/* Destination Type Selection */} +
+ {destinationTypes.map((type) => { + const Icon = type.icon; + return ( + + ); + })} +
+ + {/* Destination-specific fields */} +
+ {formData.backup_destination_type === 'local' && ( + <> +
+ + handleChange('backup_destination_path', e.target.value)} + placeholder={t('backup.configuration.fields.destinationPathPlaceholder')} + required + /> +

+ {t('backup.configuration.fields.destinationPathHelp')} +

+
+ + )} + + {formData.backup_destination_type === 'rsync' && ( + <> +
+
+ + handleChange('backup_rsync_host', e.target.value)} + placeholder={t('backup.configuration.fields.rsyncHostPlaceholder')} + required + /> +
+
+ + handleChange('backup_rsync_user', e.target.value)} + placeholder={t('backup.configuration.fields.rsyncUserPlaceholder')} + required + /> +
+
+
+ + handleChange('backup_rsync_path', e.target.value)} + placeholder={t('backup.configuration.fields.rsyncPathPlaceholder')} + required + /> +
+
+ +
+